* [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets
@ 2026-04-15 7:01 Arthur Bied-Charreton
2026-04-15 7:01 ` [PATCH proxmox v3 01/23] Add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
` (22 more replies)
0 siblings, 23 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:01 UTC (permalink / raw)
To: pve-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 Exchange Online OAuth2 rotates it at every use.
This series solves this by introducing state files. Each SMTP endpoint
gets its own to avoid having to lock all endpoints every time a state
update is made (which can be on every notification for Microsoft
targets). These files are managed entirely from the Rust side.
Tokens are refreshed every time a notification is sent, and proactively
via pveupdate/proxmox-backup-daily-update. State file updates are
atomic (sys::fs::replace_file).
Note that this is technically racy, since multiple notifications could
be sent at the same time from different nodes, but that is okay in
practice given the implementations of the providers we support:
* Google: The token is refreshed in-place, meaning its lifetime is
extended at every use without it being *actually* rotated [1], so no
changes to the state file.
* Microsoft: The token is rotated at each request, however tokens have
a fixed lifetime of 90 days, meaning the old token can still be used.
Concurrent access just means we are letting the last updater win, but
in both cases we end up with a valid token that can be used for
subsequent exchanges [2].
For access token requests in proxmox-notify, the oauth2 crate is used
with a local ureq backend (newtype over ureq::Agent). oauth2's ureq
feature is currently patched out in Debian due to a ureq 2/3 version
mismatch (oauth2 still depends on ureq 2 and Debian only packages ureq
3) [3].
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
(new parameter).
Known issues:
- Microsoft OAuth2 support is untested (no test tenant, somehow
impossible to create a free test account). It is implemented by
following the Microsoft Entra ID docs, but there might be something
I overlooked. I would highly appreciate it if someone was able to
test it with their account.
Changes since RFC:
https://lore.proxmox.com/pve-devel/20260213160415.609868-1-a.bied-charreton@proxmox.com/T/#t
Changes since v1:
https://lore.proxmox.com/pve-devel/20260325131444.366808-1-a.bied-charreton@proxmox.com/T/#t
Changes since v2:
proxmox-backup:
* Introduce XOAUTH2 into proxmox-backup
proxmox:
* Make oauth2 and ureq workspace dependencies, and update crates
using them
proxmox-notify:
* Add migration code to {add,update}_endpoint to keep supporting
the old way to set the authentication method for SMTP endpoints
and update old configs.
* Update outdated doc comments in xoauth2::State
* Make xoauth2::State::{load,save,delete} pub instead of pub(crate)
* Guard oauth-related imports and Context trait methods behind smtp
feature flag
* Update state file paths
* Use create_path for recursively creating state file directory in
xoauth2::State::save
* Replace impl From<Option<String>> for State with State::new(String)
proxmox-widget-toolkit:
* Use gettext in OAuth2 handlers' error messages
* Move OAuth2 callback handler to Proxmox.OAuth2
[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
[3] https://git.proxmox.com/?p=debcargo-conf.git;a=blob;f=src/oauth2/debian/patches/disable-ureq.patch;h=828b883a83a86927c5cd32df055226a5e78e8bea;hb=refs/heads/proxmox/trixie
proxmox:
Arthur Bied-Charreton (8):
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: 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 | 17 +-
proxmox-notify/debian/control | 68 ++---
proxmox-notify/src/api/common.rs | 16 ++
proxmox-notify/src/api/smtp.rs | 151 +++++++++--
proxmox-notify/src/context/mod.rs | 17 ++
proxmox-notify/src/context/pbs.rs | 23 ++
proxmox-notify/src/context/pve.rs | 25 +-
proxmox-notify/src/context/test.rs | 22 ++
proxmox-notify/src/endpoints/smtp.rs | 241 +++++++++++++++--
proxmox-notify/src/endpoints/smtp/xoauth2.rs | 271 +++++++++++++++++++
proxmox-notify/src/lib.rs | 33 ++-
proxmox-openid/Cargo.toml | 2 +-
14 files changed, 794 insertions(+), 96 deletions(-)
create mode 100644 proxmox-notify/src/endpoints/smtp/xoauth2.rs
proxmox-perl-rs:
Arthur Bied-Charreton (2):
pve-rs: notify: smtp: add OAuth2 parameters to bindings
pve-rs: notify: Add binding for triggering state refresh
common/src/bindings/notify.rs | 78 ++++++++++++-----------------------
1 file changed, 26 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 | 116 +++++++++++++++
src/panel/SmtpEditPanel.js | 263 +++++++++++++++++++++++++++++++--
src/window/EndpointEditBase.js | 1 +
3 files changed, 367 insertions(+), 13 deletions(-)
pve-manager:
Arthur Bied-Charreton (5):
notifications: smtp: api: Add XOAUTH2 parameters
notifications: Add trigger-state-refresh endpoint
notifications: Trigger notification target refresh in pveupdate
login: Handle OAuth2 callback
fix #7238: notifications: smtp: Add XOAUTH2 support
PVE/API2/Cluster/Notifications.pm | 150 +++++++++++++++++++++++++-----
bin/pveupdate | 9 ++
www/manager6/Utils.js | 10 ++
www/manager6/Workspace.js | 2 +
4 files changed, 148 insertions(+), 23 deletions(-)
proxmox-backup:
Arthur Bied-Charreton (4):
notifications: Add XOAUTH2 parameters to endpoints
login: Handle OAuth2 callback
fix #7238: notifications: smtp: Add XOAUTH2 support
daily-update: Refresh OAuth2 state for SMTP notification endpoints
src/api2/config/notifications/smtp.rs | 36 ++++++++++++++++++++++++---
src/bin/proxmox-daily-update.rs | 9 +++++++
www/Application.js | 2 ++
www/Utils.js | 1 +
4 files changed, 45 insertions(+), 3 deletions(-)
pve-docs:
Arthur Bied-Charreton (1):
notifications: Add OAuth2 section to SMTP targets docs
notifications.adoc | 99 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
Summary over all repositories:
27 files changed, 1479 insertions(+), 187 deletions(-)
--
Generated by murpp 0.11.0
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 01/23] Add oauth2 and ureq to workspace dependencies
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
@ 2026-04-15 7:01 ` Arthur Bied-Charreton
2026-04-15 7:01 ` [PATCH proxmox v3 02/23] notify: smtp: Introduce xoauth2 module Arthur Bied-Charreton
` (21 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:01 UTC (permalink / raw)
To: pve-devel
Since oauth2 and ureq will be needed by proxmox-notify for XOAUTH2
support, 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>
---
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..ecc36d65 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -119,6 +119,7 @@ nix = "0.29"
nom = "7"
# used by proxmox-disks, can be replaced by OnceLock from std once it supports get_or_try_init
once_cell = "1.3.1"
+oauth2 = { version = "5", default-features = false }
openssl = "0.10"
pam-sys = "0.5"
percent-encoding = "2.1"
@@ -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..c1240fdf 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 = { version = "3.0", 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..aed788d7 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 = { version = "3", 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 [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 02/23] notify: smtp: Introduce xoauth2 module
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
2026-04-15 7:01 ` [PATCH proxmox v3 01/23] Add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
@ 2026-04-15 7:01 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 03/23] notify: smtp: Introduce state management Arthur Bied-Charreton
` (20 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:01 UTC (permalink / raw)
To: pve-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 ureq::Agent implementing the
SyncHttpClient trait is added to allow using ureq as backend, since
Debian patched out oauth2's ureq feature due to a ureq 2/3 version
mismatch [0].
[0]
https://git.proxmox.com/?p=debcargo-conf.git;a=blob;f=src/oauth2/debian/patches/disable-ureq.patch;h=828b883a83a86927c5cd32df055226a5e78e8bea;hb=refs/heads/proxmox/trixie
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/Cargo.toml | 5 +-
proxmox-notify/debian/control | 37 ++--
proxmox-notify/src/endpoints/smtp.rs | 2 +
proxmox-notify/src/endpoints/smtp/xoauth2.rs | 174 +++++++++++++++++++
4 files changed, 201 insertions(+), 17 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..c0d9921b 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -19,6 +19,9 @@ http = { workspace = true, optional = true }
lettre = { workspace = true, optional = true }
tracing.workspace = true
mail-parser = { workspace = true, optional = true }
+oauth2 = { workspace = true, optional = true }
+ureq = { workspace = true, optional = true, features = ["native-tls"] }
+
openssl.workspace = true
percent-encoding = { workspace = true, optional = true }
regex.workspace = true
@@ -44,5 +47,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:ureq", "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..1b5c4068 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>,
@@ -33,7 +34,8 @@ Build-Depends-Arch: cargo:native <!nocheck>,
librust-serde-1+default-dev <!nocheck>,
librust-serde-1+derive-dev <!nocheck>,
librust-serde-json-1+default-dev <!nocheck>,
- librust-tracing-0.1+default-dev <!nocheck>
+ librust-tracing-0.1+default-dev <!nocheck>,
+ librust-ureq-3+native-tls-dev <!nocheck>
Maintainer: Proxmox Support Team <support@proxmox.com>
Standards-Version: 4.7.2
Vcs-Git: git://git.proxmox.com/git/proxmox.git
@@ -52,9 +54,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 +126,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 +142,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 +163,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 +179,10 @@ 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-ureq-3+native-tls-dev
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..06da0e79
--- /dev/null
+++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
@@ -0,0 +1,174 @@
+use oauth2::{
+ basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse,
+ TokenUrl,
+};
+
+use crate::Error;
+
+/// This newtype implements the `SyncHttpClient` trait for [`ureq::Agent`]. This allows
+/// us to avoid pulling in a different backend like `reqwest`.
+///
+/// Debian patched out `[0]` the `ureq` backend due to a `ureq` 2-3 version
+/// mismatch in the `oauth2` crate.
+///
+/// There is an open PR `[1]` in `oauth2`, once/if this is merged, we can drop the
+/// custom client implementation.
+///
+/// `[0]`
+/// https://git.proxmox.com/?p=debcargo-conf.git;a=blob;f=src/oauth2/debian/patches/disable-ureq.patch;h=828b883a83a86927c5cd32df055226a5e78e8bea;hb=refs/heads/proxmox/trixie
+///
+/// `[1]` https://github.com/ramosbugs/oauth2-rs/pull/338
+pub(crate) struct UreqSyncHttpClient(ureq::Agent);
+
+impl Default for UreqSyncHttpClient {
+ /// Set `max_redirects` to 0 to prevent SSRF, see
+ /// https://docs.rs/oauth2/latest/oauth2/#security-warning
+ fn default() -> Self {
+ Self(ureq::Agent::new_with_config(
+ ureq::Agent::config_builder()
+ .max_redirects(0)
+ .tls_config(
+ ureq::tls::TlsConfig::builder()
+ .provider(ureq::tls::TlsProvider::NativeTls)
+ .build(),
+ )
+ .build(),
+ ))
+ }
+}
+
+impl oauth2::SyncHttpClient for UreqSyncHttpClient {
+ type Error = oauth2::HttpClientError<ureq::Error>;
+
+ fn call(&self, request: oauth2::HttpRequest) -> Result<oauth2::HttpResponse, Self::Error> {
+ let uri = request.uri().to_string();
+
+ let response = match request.method() {
+ &http::Method::POST => {
+ let req = request
+ .headers()
+ .iter()
+ .fold(self.0.post(&uri), |req, (name, value)| {
+ req.header(name, value)
+ });
+ req.send(request.body()).map_err(Box::new)?
+ }
+ &http::Method::GET => {
+ let req = request
+ .headers()
+ .iter()
+ .fold(self.0.get(&uri), |req, (name, value)| {
+ req.header(name, value)
+ });
+ req.call().map_err(Box::new)?
+ }
+ m => {
+ return Err(oauth2::HttpClientError::Other(format!(
+ "unexpected method: {m}"
+ )));
+ }
+ };
+
+ let mut builder = http::Response::builder().status(response.status());
+
+ if let Some(content_type) = response.headers().get(http::header::CONTENT_TYPE) {
+ builder = builder.header(http::header::CONTENT_TYPE, content_type);
+ }
+
+ let (_, mut body) = response.into_parts();
+
+ let body = body.read_to_vec().map_err(Box::new)?;
+
+ builder.body(body).map_err(oauth2::HttpClientError::Http)
+ }
+}
+
+/// 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.
+///
+/// This always yields a new refresh token, which should be persisted on a best-effort
+/// basis, replacing the one that was passed to this function.
+///
+/// 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.
+///
+/// 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(&UreqSyncHttpClient::default())
+ .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(),
+ })
+}
+
+/// 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(&UreqSyncHttpClient::default())
+ .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 [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 03/23] notify: smtp: Introduce state management
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
2026-04-15 7:01 ` [PATCH proxmox v3 01/23] Add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
2026-04-15 7:01 ` [PATCH proxmox v3 02/23] notify: smtp: Introduce xoauth2 module Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 04/23] notify: smtp: Factor out transport building logic Arthur Bied-Charreton
` (19 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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, in order to make migration as easy as possible in
a future where we might want to move towards KV storage instead
of files for secret management. It is made specific to oauth state,
because this implementation assumes invariants that hold for oauth2
refresh tokens (documented in the smtp::xoauth2 module's doc comments),
but are likely to be incorrect for other kinds of state that may be added
in the future.
The State struct is public in order to support the long-term goal for
the Context trait to be implemented by the products themselves.
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>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/Cargo.toml | 12 ++-
proxmox-notify/debian/control | 41 +++------
proxmox-notify/src/context/mod.rs | 17 ++++
proxmox-notify/src/context/pbs.rs | 23 +++++
proxmox-notify/src/context/pve.rs | 25 ++++-
proxmox-notify/src/context/test.rs | 22 +++++
proxmox-notify/src/endpoints/smtp.rs | 2 +
proxmox-notify/src/endpoints/smtp/xoauth2.rs | 97 ++++++++++++++++++++
proxmox-notify/src/lib.rs | 12 +++
9 files changed, 218 insertions(+), 33 deletions(-)
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index c0d9921b..873a7d6f 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -36,16 +36,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:ureq", "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 1b5c4068..b8db398c 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>,
@@ -51,6 +52,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,
@@ -60,6 +62,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-~~),
@@ -73,14 +76,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"
@@ -126,8 +136,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}),
@@ -136,35 +145,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..a3942e5f 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,21 @@ pub trait Context: Send + Sync + Debug {
namespace: Option<&str>,
source: TemplateSource,
) -> Result<Option<String>, Error>;
+ /// Load OAuth state for `endpoint_name`.
+ ///
+ /// The state file does not need to be locked, it is okay to just let the faster node "win"
+ /// as long as the invariants documented by [`smtp::xoauth2::get_microsoft_token`] and
+ /// [`smtp::xoauth2::get_google_token`] hold, see those functions' doc comments for details.
+ #[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`.
+ ///
+ /// The state file does not need to be locked, it is okay to just let the faster node "win"
+ /// as long as the invariants documented by [`smtp::xoauth2::get_microsoft_token`] and
+ /// [`smtp::xoauth2::get_google_token`] hold, see those functions' doc comments for details.
+ #[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..8c5fce6b 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,26 @@ 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)),
+ ),
+ 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..2befd53b 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,24 @@ 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)),
+ ),
+ 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..9a653343 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,22 @@ 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(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 06da0e79..1df5b447 100644
--- a/proxmox-notify/src/endpoints/smtp/xoauth2.rs
+++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
@@ -1,10 +1,107 @@
+use std::path::Path;
+
use oauth2::{
basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse,
TokenUrl,
};
+use serde::{Deserialize, Serialize};
+use tracing::{debug, error};
use crate::Error;
+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
+#[serde(rename_all = "kebab-case")]
+/// Persistent state for XOAUTH2 SMTP endpoints.
+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 successful token refresh.
+ 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 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` with `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,
+ options: proxmox_sys::fs::CreateOptions,
+ ) -> Result<(), Error> {
+ let path_str = path.as_ref().to_string_lossy();
+ let parent = path.as_ref().parent().unwrap();
+
+ debug!("attempting to persist state at {path_str}");
+
+ proxmox_sys::fs::create_path(parent, Some(options), Some(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(), 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
+ }
+}
+
/// This newtype implements the `SyncHttpClient` trait for [`ureq::Agent`]. This allows
/// us to avoid pulling in a different backend like `reqwest`.
///
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 [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 04/23] notify: smtp: Factor out transport building logic
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (2 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox v3 03/23] notify: smtp: Introduce state management Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 05/23] notify: smtp: Update API with OAuth2 parameters Arthur Bied-Charreton
` (18 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-devel
As a preparatory step for introducing XOAUTH2 support, which will make
the transport building logic more complex, factor it out 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 [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 05/23] notify: smtp: Update API with OAuth2 parameters
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (3 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox v3 04/23] notify: smtp: Factor out transport building logic Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 06/23] notify: smtp: Infer auth method for backwards compatibility Arthur Bied-Charreton
` (17 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
proxmox-notify/src/api/smtp.rs | 99 ++++++++++++++++++++--------
proxmox-notify/src/endpoints/smtp.rs | 42 ++++++++++++
proxmox-notify/src/lib.rs | 2 +-
3 files changed, 116 insertions(+), 27 deletions(-)
diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index 470701bf..b265c26a 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -1,9 +1,10 @@
use proxmox_http_error::HttpError;
use crate::api::{http_bail, http_err};
+use crate::context::context;
use crate::endpoints::smtp::{
- DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
- SmtpPrivateConfigUpdater, SMTP_TYPENAME,
+ DeleteableSmtpProperty, SmtpAuthMethod, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
+ SmtpPrivateConfigUpdater, State, SMTP_TYPENAME,
};
use crate::Config;
@@ -30,6 +31,31 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result<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,15 @@ 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`
+///
+/// `oauth2_refresh_token` is initially passed through the API when an OAuth2
+/// endpoint is created/updated, however its state is not managed through a
+/// config, which is why it is passed separately.
pub fn 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 +114,16 @@ pub fn add_endpoint(
/// Returns a `HttpError` if:
/// - the configuration could not be saved (`500 Internal server error`)
/// - mailto *and* mailto_user are both set to `None`
+///
+/// `oauth2_refresh_token` is initially passed through the API when an OAuth2
+/// endpoint is created/updated, however its state is not managed through a
+/// config, which is why it is passed separately.
pub fn update_endpoint(
config: &mut Config,
name: &str,
updater: SmtpConfigUpdater,
private_endpoint_config_updater: SmtpPrivateConfigUpdater,
+ oauth2_refresh_token: Option<String>,
delete: Option<&[DeleteableSmtpProperty]>,
digest: Option<&[u8]>,
) -> Result<(), HttpError> {
@@ -103,20 +139,20 @@ pub fn update_endpoint(
DeleteableSmtpProperty::Disable => endpoint.disable = None,
DeleteableSmtpProperty::Mailto => endpoint.mailto.clear(),
DeleteableSmtpProperty::MailtoUser => endpoint.mailto_user.clear(),
- DeleteableSmtpProperty::Password => super::set_private_config_entry(
- config,
- SmtpPrivateConfig {
- name: name.to_string(),
- password: None,
- },
- SMTP_TYPENAME,
- name,
- )?,
+ DeleteableSmtpProperty::Password => {
+ update_private_config(config, name, |c| c.password = None)?
+ }
+ DeleteableSmtpProperty::AuthMethod => endpoint.auth_method = None,
+ DeleteableSmtpProperty::OAuth2ClientId => endpoint.oauth2_client_id = None,
+ DeleteableSmtpProperty::OAuth2ClientSecret => {
+ update_private_config(config, name, |c| c.oauth2_client_secret = None)?
+ }
+ DeleteableSmtpProperty::OAuth2TenantId => endpoint.oauth2_tenant_id = None,
DeleteableSmtpProperty::Port => endpoint.port = None,
DeleteableSmtpProperty::Username => endpoint.username = None,
}
}
- }
+ };
if let Some(mailto) = updater.mailto {
endpoint.mailto = mailto;
@@ -139,29 +175,24 @@ pub fn update_endpoint(
if let Some(mode) = updater.mode {
endpoint.mode = Some(mode);
}
- if let Some(password) = private_endpoint_config_updater.password {
- super::set_private_config_entry(
- config,
- SmtpPrivateConfig {
- name: name.into(),
- password: Some(password),
- },
- SMTP_TYPENAME,
- name,
- )?;
+ if let Some(auth_method) = updater.auth_method {
+ endpoint.auth_method = Some(auth_method);
}
-
if let Some(author) = updater.author {
endpoint.author = Some(author);
}
-
if let Some(comment) = updater.comment {
endpoint.comment = Some(comment);
}
-
if let Some(disable) = updater.disable {
endpoint.disable = Some(disable);
}
+ if let Some(oauth2_client_id) = updater.oauth2_client_id {
+ endpoint.oauth2_client_id = Some(oauth2_client_id);
+ }
+ if let Some(oauth2_tenant_id) = updater.oauth2_tenant_id {
+ endpoint.oauth2_tenant_id = Some(oauth2_tenant_id);
+ }
if endpoint.mailto.is_empty() && endpoint.mailto_user.is_empty() {
http_bail!(
@@ -169,6 +200,14 @@ pub fn update_endpoint(
"must at least provide one recipient, either in mailto or in mailto-user"
);
}
+ update_private_config(config, name, |c| {
+ if let Some(password) = private_endpoint_config_updater.password {
+ c.password = Some(password);
+ }
+ if let Some(oauth2_client_secret) = private_endpoint_config_updater.oauth2_client_secret {
+ c.oauth2_client_secret = Some(oauth2_client_secret);
+ }
+ })?;
config
.config
@@ -195,6 +234,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError>
super::ensure_safe_to_delete(config, name)?;
super::remove_private_config_entry(config, name)?;
+
config.config.sections.remove(name);
Ok(())
@@ -204,7 +244,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError>
pub mod tests {
use super::*;
use crate::api::test_helpers::*;
- use crate::endpoints::smtp::SmtpMode;
+ use crate::endpoints::smtp::{SmtpAuthMethod, SmtpMode};
pub fn add_smtp_endpoint_for_test(config: &mut Config, name: &str) -> Result<(), HttpError> {
add_endpoint(
@@ -217,6 +257,7 @@ pub mod tests {
author: Some("root".into()),
comment: Some("Comment".into()),
mode: Some(SmtpMode::StartTls),
+ auth_method: Some(SmtpAuthMethod::Plain),
server: "localhost".into(),
port: Some(555),
username: Some("username".into()),
@@ -225,7 +266,9 @@ pub mod tests {
SmtpPrivateConfig {
name: name.into(),
password: Some("password".into()),
+ oauth2_client_secret: None,
},
+ None,
)?;
assert!(get_endpoint(config, name).is_ok());
@@ -256,6 +299,7 @@ pub mod tests {
Default::default(),
None,
None,
+ None,
)
.is_err());
@@ -273,6 +317,7 @@ pub mod tests {
Default::default(),
Default::default(),
None,
+ None,
Some(&[0; 32]),
)
.is_err());
@@ -304,6 +349,7 @@ pub mod tests {
},
Default::default(),
None,
+ None,
Some(&digest),
)?;
@@ -327,6 +373,7 @@ pub mod tests {
"smtp-endpoint",
Default::default(),
Default::default(),
+ None,
Some(&[
DeleteableSmtpProperty::Author,
DeleteableSmtpProperty::MailtoUser,
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 48fdbd8f..b92f96f0 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -83,11 +83,21 @@ pub struct SmtpConfig {
pub port: Option<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.
+ #[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 +144,39 @@ pub enum DeleteableSmtpProperty {
MailtoUser,
/// Delete `password`
Password,
+ /// Delete `auth_method`
+ AuthMethod,
+ /// Delete `oauth2_client_id`
+ #[serde(rename = "oauth2-client-id")]
+ OAuth2ClientId,
+ /// Delete `oauth2_client_secret`
+ #[serde(rename = "oauth2-client-secret")]
+ OAuth2ClientSecret,
+ /// Delete `oauth2_tenant_id`
+ #[serde(rename = "oauth2-tenant-id")]
+ OAuth2TenantId,
/// Delete `port`
Port,
/// Delete `username`
Username,
}
+/// Authentication mode to use for SMTP.
+#[api]
+#[derive(Serialize, Deserialize, Clone, Debug, Default, Copy)]
+#[serde(rename_all = "kebab-case")]
+pub enum SmtpAuthMethod {
+ /// Username + password
+ #[default]
+ Plain,
+ /// Google OAuth2
+ #[serde(rename = "google-oauth2")]
+ GoogleOAuth2,
+ /// Microsoft OAuth2
+ #[serde(rename = "microsoft-oauth2")]
+ MicrosoftOAuth2,
+}
+
#[api]
#[derive(Serialize, Deserialize, Clone, Updater, Debug)]
#[serde(rename_all = "kebab-case")]
@@ -150,9 +187,14 @@ pub struct SmtpPrivateConfig {
/// Name of the endpoint
#[updater(skip)]
pub name: String,
+
/// The password to use during authentication.
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
+
+ /// OAuth2 client secret
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_client_secret: Option<String>,
}
/// A sendmail notification endpoint.
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 619dd7db..d443b738 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -9,7 +9,7 @@ use context::context;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::Value;
-use tracing::{error, info};
+use tracing::{debug, error, info};
use proxmox_schema::api;
use proxmox_section_config::SectionConfigData;
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 06/23] notify: smtp: Infer auth method for backwards compatibility
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (4 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox v3 05/23] notify: smtp: Update API with OAuth2 parameters Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 07/23] notify: smtp: Add state handling logic Arthur Bied-Charreton
` (16 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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 | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index b265c26a..71284f63 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -56,6 +56,20 @@ fn get_private_config(config: &Config, name: &str) -> Result<SmtpPrivateConfig,
})
}
+/// Before the `auth_method` field was introduced into [`SmtpConfig`], the auhentication method
+/// was inferred from the presence of a password in the config. This function should be called
+/// in `add_endpoint` and `update_endpoint` to update old configs and keep backwards compatibility.
+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.
@@ -90,11 +104,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)
@@ -209,6 +225,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 [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 07/23] notify: smtp: Add state handling logic
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (5 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox v3 06/23] notify: smtp: Infer auth method for backwards compatibility Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 08/23] notify: smtp: Add XOAUTH2 authentication support Arthur Bied-Charreton
` (15 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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.
Since trigger_state_refresh is called in pveupdate, it may be called
multiple times in quick succession by the different nodes in a
cluster. In order to avoid unnecessary churn on the state files, the
last_refreshed field is used to check if the state has been refreshed
shortly before, and skip the update if that is the case.
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 | 16 ++++++
proxmox-notify/src/api/smtp.rs | 32 +++++++++++
proxmox-notify/src/endpoints/smtp.rs | 79 +++++++++++++++++++++++++++-
proxmox-notify/src/lib.rs | 19 +++++++
4 files changed, 145 insertions(+), 1 deletion(-)
diff --git a/proxmox-notify/src/api/common.rs b/proxmox-notify/src/api/common.rs
index fa2356e2..1e6b7d46 100644
--- a/proxmox-notify/src/api/common.rs
+++ b/proxmox-notify/src/api/common.rs
@@ -3,6 +3,22 @@ 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.
+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 71284f63..b0df47a8 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -111,6 +111,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)
@@ -227,6 +240,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)
@@ -253,6 +278,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 b92f96f0..3e7175d2 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_SECONDS: Duration = Duration::from_secs(60 * 60 * 12);
mod xoauth2;
@@ -204,6 +207,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)
@@ -335,6 +375,43 @@ 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 refresh job is configured in pveupdate, which runs once for each node.
+ // Don't refresh if we already did it recently.
+ if SystemTime::now()
+ .duration_since(UNIX_EPOCH + Duration::from_secs(state.last_refreshed as u64))
+ .map_err(|e| Error::Generic(e.to_string()))?
+ < SMTP_STATE_REFRESH_CUTOFF_SECONDS
+ {
+ 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 d443b738..2025bd64 100644
--- a/proxmox-notify/src/lib.rs
+++ b/proxmox-notify/src/lib.rs
@@ -169,6 +169,11 @@ pub trait Endpoint {
/// Check if the endpoint is disabled
fn disabled(&self) -> bool;
+
+ /// Refresh endpoint's state
+ fn trigger_state_refresh(&self) -> Result<(), Error> {
+ Ok(())
+ }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -605,6 +610,20 @@ impl Bus {
Ok(())
}
+
+ /// Refresh all endpoints' internal state.
+ ///
+ /// 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 [flat|nested] 24+ messages in thread
* [PATCH proxmox v3 08/23] notify: smtp: Add XOAUTH2 authentication support
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (6 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox v3 07/23] notify: smtp: Add state handling logic Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-perl-rs v3 09/23] pve-rs: notify: smtp: add OAuth2 parameters to bindings Arthur Bied-Charreton
` (14 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-devel
Extend the transport building logic to authenticate via XOAUTH2 if
configured, and manage the related state updates.
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, add an `auth_method` method
ensuring configurations that used PLAIN auth before this series
continue to do so without having to set it explicitly.
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 | 96 ++++++++++++++++++++++++----
1 file changed, 83 insertions(+), 13 deletions(-)
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 3e7175d2..45513cd7 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};
@@ -200,7 +201,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,
@@ -244,22 +245,91 @@ 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`].
+ ///
+ /// If OAuth2 authentication is configured, this method will additionally load,
+ /// update and store the OAuth2-related state for this endpoint.
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)?;
+
+ let state = state
+ .set_oauth2_refresh_token(Some(
+ token_exchange_result
+ .refresh_token
+ .map(|t| t.into_secret())
+ .unwrap_or_else(|| refresh_token),
+ ))
+ .set_last_refreshed(proxmox_time::epoch_i64());
+
+ context().save_oauth_state(self.name(), Some(state))?;
+
+ 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 [flat|nested] 24+ messages in thread
* [PATCH proxmox-perl-rs v3 09/23] pve-rs: notify: smtp: add OAuth2 parameters to bindings
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (7 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox v3 08/23] notify: smtp: Add XOAUTH2 authentication support Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-perl-rs v3 10/23] pve-rs: notify: Add binding for triggering state refresh Arthur Bied-Charreton
` (13 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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.
The oauth2_refresh_token is passed as a standalone parameter, since it
is not supposed to be written to the notifications config, it will be
managed separately on the Rust side.
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 [flat|nested] 24+ messages in thread
* [PATCH proxmox-perl-rs v3 10/23] pve-rs: notify: Add binding for triggering state refresh
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (8 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-perl-rs v3 09/23] pve-rs: notify: smtp: add OAuth2 parameters to bindings Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 11/23] utils: Add OAuth2 flow handlers Arthur Bied-Charreton
` (12 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-devel
Expose the new `trigger_state_refresh` proxmox-notify API to allow
update jobs to manually 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 | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/common/src/bindings/notify.rs b/common/src/bindings/notify.rs
index ff1e6cf..1f43d67 100644
--- a/common/src/bindings/notify.rs
+++ b/common/src/bindings/notify.rs
@@ -141,6 +141,19 @@ pub mod proxmox_rs_notify {
api::common::send(&config, ¬ification)
}
+ /// Method: Refresh the state for all endpoints.
+ ///
+ /// This iterates through all configured targets, refreshing their state if needed.
+ ///
+ /// See [`api::common::refresh_targets`]
+ #[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 [flat|nested] 24+ messages in thread
* [PATCH proxmox-widget-toolkit v3 11/23] utils: Add OAuth2 flow handlers
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (9 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-perl-rs v3 10/23] pve-rs: notify: Add binding for triggering state refresh Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 12/23] utils: oauth2: Add callback handler Arthur Bied-Charreton
` (11 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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 | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 91 insertions(+)
diff --git a/src/Utils.js b/src/Utils.js
index 5457ffa..ceed4c8 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1723,6 +1723,97 @@ Ext.define('Proxmox.Utils', {
},
});
+Ext.define('Proxmox.OAuth2', {
+ singleton: true,
+
+ handleGoogleFlow: function (clientId, clientSecret) {
+ return this._handleFlow({
+ clientId,
+ clientSecret,
+ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ tokenUrl: 'https://oauth2.googleapis.com/token',
+ scope: 'https://mail.google.com',
+ extraAuthParams: {
+ access_type: 'offline',
+ prompt: 'consent',
+ },
+ });
+ },
+
+ handleMicrosoftFlow: function (clientId, clientSecret, tenantId) {
+ return this._handleFlow({
+ clientId,
+ clientSecret,
+ authUrl: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
+ tokenUrl: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
+ 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}`;
+
+ // 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 channel = new BroadcastChannel(channelName);
+ let popup = window.open(authUrl);
+ if (!popup) {
+ reject(new Error(gettext('Could not open authentication window')));
+ return;
+ }
+
+ channel.addEventListener('message', async (event) => {
+ if (popup && !popup.closed) {
+ popup.close();
+ }
+ channel.close();
+
+ try {
+ let response = await fetch(config.tokenUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: event.data.code,
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ redirect_uri: redirectUri,
+ }),
+ });
+
+ let tokens = await response.json();
+ if (!tokens.refresh_token) {
+ reject(tokens.error_description || gettext('Token exchange failed'));
+ }
+ resolve(tokens.refresh_token);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+ },
+});
+
Ext.define('Proxmox.Async', {
singleton: true,
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH proxmox-widget-toolkit v3 12/23] utils: oauth2: Add callback handler
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (10 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 11/23] utils: Add OAuth2 flow handlers Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 13/23] notifications: Add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
` (10 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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 | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/src/Utils.js b/src/Utils.js
index ceed4c8..f4e2cd1 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1812,6 +1812,31 @@ 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, which
+ // should contain a UUID identifying a BroadcastChannel in the state parameter.
+ // 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 && scope && state) {
+ try {
+ let { channelName } = JSON.parse(decodeURIComponent(state));
+ let bc = new BroadcastChannel(channelName);
+ bc.postMessage({ code, scope });
+ } 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.
+ }
+ }
+ }
});
Ext.define('Proxmox.Async', {
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH proxmox-widget-toolkit v3 13/23] notifications: Add opt-in OAuth2 support for SMTP targets
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (11 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 12/23] utils: oauth2: Add callback handler Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 14/23] notifications: smtp: api: Add XOAUTH2 parameters Arthur Bied-Charreton
` (9 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/panel/SmtpEditPanel.js | 263 +++++++++++++++++++++++++++++++--
src/window/EndpointEditBase.js | 1 +
2 files changed, 251 insertions(+), 13 deletions(-)
diff --git a/src/panel/SmtpEditPanel.js b/src/panel/SmtpEditPanel.js
index 37e4d51..e6c717b 100644
--- a/src/panel/SmtpEditPanel.js
+++ b/src/panel/SmtpEditPanel.js
@@ -6,10 +6,24 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
type: 'smtp',
+ enableOAuth2: false,
+
+ initConfig: function (config) {
+ this.callParent(arguments);
+ this.getViewModel().set('enableOAuth2', config.enableOAuth2 || false);
+ return config;
+ },
+
viewModel: {
xtype: 'viewmodel',
data: {
+ enableOAuth2: false,
mode: 'tls',
+ authMethod: 'plain',
+ oAuth2ClientId: '',
+ oAuth2ClientSecret: '',
+ oAuth2TenantId: '',
+ oAuth2RefreshToken: '',
authentication: true,
originalAuthentication: true,
},
@@ -39,6 +53,62 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
return shouldShowUnchanged ? gettext('Unchanged') : '';
},
+ isPlainAuthentication: function (get) {
+ return get('authMethod') === 'plain';
+ },
+ isOAuth2Authentication: function (get) {
+ if (!get('enableOAuth2')) {
+ return false;
+ }
+ let authMethod = get('authMethod');
+ return authMethod === 'google-oauth2' || authMethod === 'microsoft-oauth2';
+ },
+ isMicrosoftOAuth2Authentication: function (get) {
+ if (!get('enableOAuth2')) {
+ return false;
+ }
+ return get('authMethod') === 'microsoft-oauth2';
+ },
+ enableAuthenticate: function (get) {
+ if (!get('enableOAuth2')) {
+ return false;
+ }
+ let clientId = get('oAuth2ClientId');
+ let clientSecret = get('oAuth2ClientSecret');
+
+ if (get('authMethod') === 'microsoft-oauth2') {
+ let tenantId = get('oAuth2TenantId');
+ return (
+ clientId &&
+ clientId.trim() !== '' &&
+ clientSecret &&
+ clientSecret.trim() !== '' &&
+ tenantId &&
+ tenantId.trim() !== ''
+ );
+ } else {
+ return (
+ clientId &&
+ clientId.trim() !== '' &&
+ clientSecret &&
+ clientSecret.trim() !== ''
+ );
+ }
+ },
+ oAuth2ClientSecretEmptyText: function (get) {
+ let isCreate = this.getView().isCreate;
+ let isOAuth2 = get('isOAuth2Authentication');
+ return !isCreate && isOAuth2 ? gettext('Unchanged') : '';
+ },
+ isAuthorized: function (get) {
+ if (!get('enableOAuth2')) {
+ return false;
+ }
+ return !!get('oAuth2RefreshToken');
+ },
+ authorizeButtonDisabled: function (get) {
+ return !get('enableAuthenticate') || get('isAuthorized');
+ },
},
},
@@ -102,11 +172,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 +202,8 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
deleteEmpty: '{!isCreate}',
},
bind: {
- disabled: '{!authentication}',
+ hidden: '{!isPlainAuthentication}',
+ disabled: '{!isPlainAuthentication}',
},
},
{
@@ -130,10 +215,113 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
allowBlank: '{!isCreate}',
},
bind: {
- disabled: '{!authentication}',
+ hidden: '{!isPlainAuthentication}',
+ disabled: '{!isPlainAuthentication}',
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: '{!isMicrosoftOAuth2Authentication}',
+ disabled: '{!isMicrosoftOAuth2Authentication}',
+ 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}',
+ },
+ getErrors: function () {
+ if (this.disabled) {
+ return [];
+ }
+ if (!this.up('pmxSmtpEditPanel').isCreate) {
+ return [];
+ }
+ if (!this.getValue()) {
+ return [''];
+ }
+ return [];
+ },
+ },
],
columnB: [
{
@@ -159,7 +347,6 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
},
},
],
-
advancedColumnB: [
{
xtype: 'proxmoxtextfield',
@@ -172,7 +359,22 @@ Ext.define('Proxmox.panel.SmtpEditPanel', {
},
},
],
+ handleOAuth2Flow: function (values) {
+ let authMethod = values['auth-method'];
+ if (authMethod === 'microsoft-oauth2') {
+ return Proxmox.OAuth2.handleMicrosoftFlow(
+ values['oauth2-client-id'],
+ values['oauth2-client-secret'],
+ values['oauth2-tenant-id'],
+ );
+ } else if (authMethod === 'google-oauth2') {
+ return Proxmox.OAuth2.handleGoogleFlow(
+ values['oauth2-client-id'],
+ values['oauth2-client-secret'],
+ );
+ }
+ },
onGetValues: function (values) {
let me = this;
@@ -180,9 +382,33 @@ 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 (!me.isCreate) {
+ if (authMethod === 'none') {
+ if (this.enableOAuth2) {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'auth-method' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'oauth2-client-id' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'oauth2-client-secret' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'oauth2-tenant-id' });
+ }
+ Proxmox.Utils.assemble_field_data(values, { delete: 'username' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'password' });
+ } else if (authMethod === 'plain' && this.enableOAuth2) {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'oauth2-client-id' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'oauth2-client-secret' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'oauth2-tenant-id' });
+ } else if (authMethod === 'microsoft-oauth2') {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'username' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'password' });
+ } else if (authMethod === 'google-oauth2') {
+ Proxmox.Utils.assemble_field_data(values, { delete: 'username' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'password' });
+ Proxmox.Utils.assemble_field_data(values, { delete: 'oauth2-tenant-id' });
+ }
}
if (values.enable) {
@@ -199,12 +425,23 @@ 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
diff --git a/src/window/EndpointEditBase.js b/src/window/EndpointEditBase.js
index 8c1bfc1..8df2016 100644
--- a/src/window/EndpointEditBase.js
+++ b/src/window/EndpointEditBase.js
@@ -47,6 +47,7 @@ Ext.define('Proxmox.window.EndpointEditBase', {
baseUrl: me.baseUrl,
type: me.type,
defaultMailAuthor: endpointConfig.defaultMailAuthor,
+ enableOAuth2: endpointConfig.enableOAuth2,
},
],
});
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH pve-manager v3 14/23] notifications: smtp: api: Add XOAUTH2 parameters
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (12 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 13/23] notifications: Add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 15/23] notifications: Add trigger-state-refresh endpoint Arthur Bied-Charreton
` (8 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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 | 116 ++++++++++++++++++++++++------
1 file changed, 93 insertions(+), 23 deletions(-)
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 8b455227..4967d823 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');
@@ -1115,23 +1147,40 @@ __PACKAGE__->register_method({
my $comment = extract_param($param, 'comment');
my $disable = extract_param($param, 'disable');
+ # Required for backwards-compatibity: before the auth-method parameter was
+ # introduced, the authentication method was inferred from the presence of
+ # a password.
+ if (!defined($auth_method)) {
+ $auth_method = defined($password) ? 'plain' : undef;
+ }
+
eval {
PVE::Notify::lock_config(sub {
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 +1236,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');
@@ -1197,23 +1251,39 @@ __PACKAGE__->register_method({
my $delete = extract_param($param, 'delete');
my $digest = extract_param($param, 'digest');
+ # Required for backwards-compatibity: before the auth-method parameter was
+ # introduced, the authentication method was inferred from the presence of
+ # a password.
+ if (!defined($auth_method)) {
+ $auth_method = defined($password) ? 'plain' : undef;
+ }
+
eval {
PVE::Notify::lock_config(sub {
my $config = PVE::Notify::read_config();
$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 => defined($mailto) ? $mailto : [],
+ 'mailto-user' => defined($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 [flat|nested] 24+ messages in thread
* [PATCH pve-manager v3 15/23] notifications: Add trigger-state-refresh endpoint
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (13 preceding siblings ...)
2026-04-15 7:02 ` [PATCH pve-manager v3 14/23] notifications: smtp: api: Add XOAUTH2 parameters Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 16/23] notifications: Trigger notification target refresh in pveupdate Arthur Bied-Charreton
` (7 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-devel
This endpoint allows triggering a refresh of the notification targets'
state, currently only to prevent OAuth2 refresh tokens from expiring.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
PVE/API2/Cluster/Notifications.pm | 34 +++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm
index 4967d823..9995e984 100644
--- a/PVE/API2/Cluster/Notifications.pm
+++ b/PVE/API2/Cluster/Notifications.pm
@@ -321,6 +321,40 @@ __PACKAGE__->register_method({
},
});
+__PACKAGE__->register_method({
+ name => "trigger_state_refresh",
+ path => 'trigger-state-refresh',
+ protected => 1,
+ method => 'POST',
+ description => 'Refresh internal state for all OAuth2 notification targets',
+ permissions => {
+ check => [
+ 'and',
+ ['perm', '/mapping/notifications', ['Mapping.Modify']],
+ [
+ 'or',
+ ['perm', '/', ['Sys.Audit', 'Sys.Modify']],
+ ['perm', '/', ['Sys.AccessNetwork']],
+ ],
+ ],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {},
+ },
+ returns => { type => 'null' },
+ code => sub {
+ eval {
+ my $config = PVE::Notify::read_config();
+ $config->trigger_state_refresh();
+ };
+
+ raise_api_error($@) if $@;
+
+ return;
+ },
+});
+
__PACKAGE__->register_method({
name => 'test_target',
path => 'targets/{name}/test',
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH pve-manager v3 16/23] notifications: Trigger notification target refresh in pveupdate
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (14 preceding siblings ...)
2026-04-15 7:02 ` [PATCH pve-manager v3 15/23] notifications: Add trigger-state-refresh endpoint Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 17/23] login: Handle OAuth2 callback Arthur Bied-Charreton
` (6 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-devel
Avoid OAuth2 refresh token expiry by forcing a notification target
refresh in PVE's daily-update job.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
bin/pveupdate | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/bin/pveupdate b/bin/pveupdate
index b1960c35..1b32b382 100755
--- a/bin/pveupdate
+++ b/bin/pveupdate
@@ -195,4 +195,13 @@ sub cleanup_tasks {
cleanup_tasks();
+eval {
+ my $config = PVE::Notify::read_config();
+ # Refresh internal state for notification targets
+ $config->trigger_state_refresh();
+};
+if (my $err = $@) {
+ syslog('err', "refresh notification targets failed: $err");
+}
+
exit(0);
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH pve-manager v3 17/23] login: Handle OAuth2 callback
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (15 preceding siblings ...)
2026-04-15 7:02 ` [PATCH pve-manager v3 16/23] notifications: Trigger notification target refresh in pveupdate Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 18/23] fix #7238: notifications: smtp: Add XOAUTH2 support Arthur Bied-Charreton
` (5 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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 [flat|nested] 24+ messages in thread
* [PATCH pve-manager v3 18/23] fix #7238: notifications: smtp: Add XOAUTH2 support
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (16 preceding siblings ...)
2026-04-15 7:02 ` [PATCH pve-manager v3 17/23] login: Handle OAuth2 callback Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 19/23] notifications: Add XOAUTH2 parameters to endpoints Arthur Bied-Charreton
` (4 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-devel
XOAUTH2 is made opt-in by proxmox-widget-toolkit. Opt into it in PVE,
introducing XOAUTH2 support for SMTP notification targets.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
www/manager6/Utils.js | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index b36e46fd..8e443ce5 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -2212,5 +2212,15 @@ 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,
+ },
+ });
},
});
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH proxmox-backup v3 19/23] notifications: Add XOAUTH2 parameters to endpoints
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (17 preceding siblings ...)
2026-04-15 7:02 ` [PATCH pve-manager v3 18/23] fix #7238: notifications: smtp: Add XOAUTH2 support Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 20/23] login: Handle OAuth2 callback Arthur Bied-Charreton
` (3 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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 [flat|nested] 24+ messages in thread
* [PATCH proxmox-backup v3 20/23] login: Handle OAuth2 callback
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (18 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-backup v3 19/23] notifications: Add XOAUTH2 parameters to endpoints Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 21/23] fix #7238: notifications: smtp: Add XOAUTH2 support Arthur Bied-Charreton
` (2 subsequent siblings)
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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/Application.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/www/Application.js b/www/Application.js
index e82ccdd0..f2c652e5 100644
--- a/www/Application.js
+++ b/www/Application.js
@@ -57,6 +57,8 @@ Ext.define('PBS.Application', {
var provider = new Ext.state.LocalStorageProvider({ prefix: 'ext-pbs-' });
Ext.state.Manager.setProvider(provider);
+ Proxmox.OAuth2.handleCallback(new URLSearchParams(window.location.search));
+
let isOpenIDLogin = Proxmox.Utils.getOpenIDRedirectionAuthorization() !== undefined;
let alreadyLoggedIn = Proxmox.Utils.authOK();
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH proxmox-backup v3 21/23] fix #7238: notifications: smtp: Add XOAUTH2 support
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (19 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-backup v3 20/23] login: Handle OAuth2 callback Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 22/23] daily-update: Refresh OAuth2 state for SMTP notification endpoints Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-docs v3 23/23] notifications: Add OAuth2 section to SMTP targets docs Arthur Bied-Charreton
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
www/Utils.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/www/Utils.js b/www/Utils.js
index 350ab820..ae4c7c65 100644
--- a/www/Utils.js
+++ b/www/Utils.js
@@ -514,6 +514,7 @@ Ext.define('PBS.Utils', {
name: 'SMTP',
ipanel: 'pmxSmtpEditPanel',
iconCls: 'fa-envelope-o',
+ enableOAuth2: true,
defaultMailAuthor: 'Proxmox Backup Server - $hostname',
},
gotify: {
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH proxmox-backup v3 22/23] daily-update: Refresh OAuth2 state for SMTP notification endpoints
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (20 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-backup v3 21/23] fix #7238: notifications: smtp: Add XOAUTH2 support Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-docs v3 23/23] notifications: Add OAuth2 section to SMTP targets docs Arthur Bied-Charreton
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-devel
Refresh tokens may expire if unused for extended periods of time. Force
refresh them in daily update to prevent this for idle endpoints.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
src/bin/proxmox-daily-update.rs | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/src/bin/proxmox-daily-update.rs b/src/bin/proxmox-daily-update.rs
index c4d68e30..363f2901 100644
--- a/src/bin/proxmox-daily-update.rs
+++ b/src/bin/proxmox-daily-update.rs
@@ -61,6 +61,15 @@ async fn do_update(rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
log::error!("error checking certificates: {err}");
}
+ match pbs_config::notifications::config() {
+ Ok(conf) => {
+ if let Err(e) = proxmox_notify::api::common::trigger_state_refresh(&conf) {
+ log::error!("Error refreshing notifications endpoints state: {e}");
+ }
+ }
+ Err(e) => log::error!("Error refreshing notifications endpoints state: {e}"),
+ }
+
// TODO: cleanup tasks like in PVE?
Ok(())
--
2.47.3
^ permalink raw reply [flat|nested] 24+ messages in thread
* [PATCH pve-docs v3 23/23] notifications: Add OAuth2 section to SMTP targets docs
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
` (21 preceding siblings ...)
2026-04-15 7:02 ` [PATCH proxmox-backup v3 22/23] daily-update: Refresh OAuth2 state for SMTP notification endpoints Arthur Bied-Charreton
@ 2026-04-15 7:02 ` Arthur Bied-Charreton
22 siblings, 0 replies; 24+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-15 7:02 UTC (permalink / raw)
To: pve-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 | 99 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
diff --git a/notifications.adoc b/notifications.adoc
index ea8fc75..f3100d6 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,98 @@ 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.yourdomain.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. See
+<<notification_targets_smtp_oauth2_google,Google-specific setup>> below for a
+workaround.
+
+===== 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.
+
+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: 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_google]]
+===== Google
+
+Google does not allow bare IP addresses as redirect URIs. To work around this,
+add an entry to `/etc/hosts` *on the machine where your browser is running*,
+i.e., your local workstation.
+
+----
+# Replace <IP> with the IP address of your PVE node
+<IP> local.oauth2-redirect.com
+----
+
+You can now register `https://local.oauth2-redirect.com:8006` as a redirect
+URI in your Google OAuth2 application, and use that same URL in the browser
+when accessing the {pve} web interface to perform the authorization flow.
+
+[[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 [flat|nested] 24+ messages in thread
end of thread, other threads:[~2026-04-15 7:11 UTC | newest]
Thread overview: 24+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-04-15 7:01 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v3 00/23] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
2026-04-15 7:01 ` [PATCH proxmox v3 01/23] Add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
2026-04-15 7:01 ` [PATCH proxmox v3 02/23] notify: smtp: Introduce xoauth2 module Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 03/23] notify: smtp: Introduce state management Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 04/23] notify: smtp: Factor out transport building logic Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 05/23] notify: smtp: Update API with OAuth2 parameters Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 06/23] notify: smtp: Infer auth method for backwards compatibility Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 07/23] notify: smtp: Add state handling logic Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox v3 08/23] notify: smtp: Add XOAUTH2 authentication support Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-perl-rs v3 09/23] pve-rs: notify: smtp: add OAuth2 parameters to bindings Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-perl-rs v3 10/23] pve-rs: notify: Add binding for triggering state refresh Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 11/23] utils: Add OAuth2 flow handlers Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 12/23] utils: oauth2: Add callback handler Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-widget-toolkit v3 13/23] notifications: Add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 14/23] notifications: smtp: api: Add XOAUTH2 parameters Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 15/23] notifications: Add trigger-state-refresh endpoint Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 16/23] notifications: Trigger notification target refresh in pveupdate Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 17/23] login: Handle OAuth2 callback Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-manager v3 18/23] fix #7238: notifications: smtp: Add XOAUTH2 support Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 19/23] notifications: Add XOAUTH2 parameters to endpoints Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 20/23] login: Handle OAuth2 callback Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 21/23] fix #7238: notifications: smtp: Add XOAUTH2 support Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH proxmox-backup v3 22/23] daily-update: Refresh OAuth2 state for SMTP notification endpoints Arthur Bied-Charreton
2026-04-15 7:02 ` [PATCH pve-docs v3 23/23] notifications: Add OAuth2 section to SMTP targets docs 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