From: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox v6 03/27] notify: smpt: add state management utilities
Date: Tue, 2 Jun 2026 10:53:55 +0200 [thread overview]
Message-ID: <20260602085419.271767-4-a.bied-charreton@proxmox.com> (raw)
In-Reply-To: <20260602085419.271767-1-a.bied-charreton@proxmox.com>
Export a new State struct in the xoauth2 module with associated
functionality for loading, updating, and persisting the OAuth2 state
for SMTP endpoints.
The API for loading and saving the state is exposed through the Context
trait, and the state struct is made public to allow each product to
implement the storage of state files itself.
The nix crate is added for the sys::stat::Mode struct, and
proxmox-sys is now pulled in unconditionally since it is used in the
Context implementations.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/Cargo.toml | 11 +-
proxmox-notify/src/context/mod.rs | 13 +++
proxmox-notify/src/context/pbs.rs | 25 +++++
proxmox-notify/src/context/pve.rs | 23 ++++
proxmox-notify/src/context/test.rs | 23 ++++
proxmox-notify/src/endpoints/smtp.rs | 2 +
proxmox-notify/src/endpoints/smtp/xoauth2.rs | 108 +++++++++++++++++++
proxmox-notify/src/lib.rs | 12 +++
8 files changed, 212 insertions(+), 5 deletions(-)
diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 6a3a3794..a72fd744 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -19,6 +19,7 @@ http = { workspace = true, optional = true }
lettre = { workspace = true, optional = true }
tracing.workspace = true
mail-parser = { workspace = true, optional = true }
+nix = { workspace = true }
openssl.workspace = true
oauth2 = { workspace = true, optional = true }
percent-encoding = { workspace = true, optional = true }
@@ -34,16 +35,16 @@ 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"] }
[features]
default = ["sendmail", "gotify", "smtp", "webhook"]
-mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys", "proxmox-sendmail/mail-forwarder"]
-sendmail = ["dep:proxmox-sys", "dep:proxmox-sendmail"]
+mail-forwarder = ["dep:mail-parser", "proxmox-sendmail/mail-forwarder"]
+sendmail = ["dep:proxmox-sendmail"]
gotify = ["dep:proxmox-http", "dep:http"]
-pve-context = ["dep:proxmox-sys"]
-pbs-context = ["dep:proxmox-sys"]
+pve-context = []
+pbs-context = []
smtp = ["dep:lettre", "dep:oauth2", "dep:proxmox-http", "dep:http"]
webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"]
diff --git a/proxmox-notify/src/context/mod.rs b/proxmox-notify/src/context/mod.rs
index 87a2a716..94f2b038 100644
--- a/proxmox-notify/src/context/mod.rs
+++ b/proxmox-notify/src/context/mod.rs
@@ -2,6 +2,8 @@ use std::fmt::Debug;
use std::sync::Mutex;
use crate::Error;
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
#[cfg(any(feature = "pve-context", feature = "pbs-context"))]
@@ -32,6 +34,17 @@ pub trait Context: Send + Sync + Debug {
namespace: Option<&str>,
source: TemplateSource,
) -> Result<Option<String>, Error>;
+ /// Load OAuth state for `endpoint_name`.
+ #[cfg(feature = "smtp")]
+ fn load_oauth_state(&self, endpoint_name: &str) -> Result<State, Error>;
+ /// Save OAuth state `state` for `endpoint_name`. Passing `None` deletes
+ /// the state file for `endpoint_name`.
+ ///
+ /// This should only be used in paths where the caller is expected to hold a lock on
+ /// the notifications config, as concurrent updates to the config and state files
+ /// could lead to invalid states.
+ #[cfg(feature = "smtp")]
+ fn save_oauth_state(&self, endpoint_name: &str, state: Option<State>) -> Result<(), Error>;
}
#[cfg(not(test))]
diff --git a/proxmox-notify/src/context/pbs.rs b/proxmox-notify/src/context/pbs.rs
index a9121548..215d0026 100644
--- a/proxmox-notify/src/context/pbs.rs
+++ b/proxmox-notify/src/context/pbs.rs
@@ -8,6 +8,8 @@ use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
use crate::Error;
use crate::context::{Context, common};
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
@@ -125,6 +127,29 @@ 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> {
+ use proxmox_sys::fs::CreateOptions;
+
+ let path =
+ format!("/var/lib/proxmox-backup/notifications/oauth-state/{endpoint_name}.json");
+ match state {
+ Some(s) => s.save(
+ path,
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o600)),
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o700)),
+ ),
+ None => Ok(State::delete(path)),
+ }
+ }
}
#[cfg(test)]
diff --git a/proxmox-notify/src/context/pve.rs b/proxmox-notify/src/context/pve.rs
index 3d9ff92e..bec25640 100644
--- a/proxmox-notify/src/context/pve.rs
+++ b/proxmox-notify/src/context/pve.rs
@@ -1,5 +1,7 @@
use crate::Error;
use crate::context::{Context, common};
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
use std::path::Path;
@@ -74,6 +76,27 @@ 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> {
+ use proxmox_sys::fs::CreateOptions;
+
+ let path = format!("/etc/pve/priv/notifications/oauth-state/{endpoint_name}.json");
+ match state {
+ Some(s) => s.save(
+ path,
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o600)),
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o700)),
+ ),
+ None => Ok(State::delete(path)),
+ }
+ }
}
pub static PVE_CONTEXT: PVEContext = PVEContext;
diff --git a/proxmox-notify/src/context/test.rs b/proxmox-notify/src/context/test.rs
index 22da38d3..f7e4f20c 100644
--- a/proxmox-notify/src/context/test.rs
+++ b/proxmox-notify/src/context/test.rs
@@ -1,5 +1,7 @@
use crate::Error;
use crate::context::Context;
+#[cfg(feature = "smtp")]
+use crate::endpoints::smtp::State;
use crate::renderer::TemplateSource;
#[derive(Debug)]
@@ -40,4 +42,25 @@ 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> {
+ use proxmox_sys::fs::CreateOptions;
+
+ let path = format!("/tmp/notifications/oauth-state/{endpoint_name}.json");
+ match state {
+ Some(s) => s.save(
+ path,
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o650)),
+ CreateOptions::new().perm(nix::sys::stat::Mode::from_bits_truncate(0o750)),
+ ),
+ None => Ok(State::delete(path)),
+ }
+ }
}
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 636828b6..f66bce79 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 4b99b3f5..ba479f3f 100644
--- a/proxmox-notify/src/endpoints/smtp/xoauth2.rs
+++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs
@@ -1,11 +1,119 @@
+use std::path::Path;
+
use oauth2::{
AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse, TokenUrl,
basic::BasicClient,
};
use proxmox_http::{HttpOptions, ProxyConfig};
+use serde::{Deserialize, Serialize};
+use tracing::{debug, error};
use crate::{Error, context::context};
+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
+#[serde(rename_all = "kebab-case")]
+/// Persistent state for XOAUTH2 SMTP endpoints.
+///
+/// This struct represents the per-endpoint state loaded and saved by [`Context::load_oauth_state`]
+/// and [`Context::save_oauth_state`] from/at product-specific paths.
+pub struct State {
+ /// OAuth2 refresh token for this endpoint.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_refresh_token: Option<String>,
+ /// Unix timestamp (seconds) of the last time a fresh refresh token was acquired and persisted,
+ /// which includes both proactive refreshes via [`Endpoint::trigger_state_refresh`] and
+ /// re-authorizations via [`api::smtp::update_endpoint`].
+ pub last_refreshed: i64,
+}
+
+impl State {
+ /// Instantiate a new [`State`]. `last_refreshed` is expected to be the UNIX
+ /// timestamp (seconds) of the instantiation time.
+ pub fn new(refresh_token: String, last_refreshed: i64) -> Self {
+ Self {
+ oauth2_refresh_token: Some(refresh_token),
+ last_refreshed,
+ }
+ }
+
+ /// Load state from `path` instantiating a default object if no state exists.
+ ///
+ /// # Errors
+ /// An [`Error`] is returned if deserialization of the state object or reading the state
+ /// file fails.
+ pub fn load<P: AsRef<Path>>(path: P) -> Result<State, Error> {
+ let path_str = path.as_ref().to_string_lossy();
+ match proxmox_sys::fs::file_get_optional_contents(&path)
+ .map_err(|e| Error::StateRetrieval(path_str.to_string(), e.into()))?
+ {
+ Some(bytes) => {
+ debug!("loaded state file from {path_str}");
+ serde_json::from_slice(&bytes)
+ .map_err(|e| Error::StateRetrieval(path_str.to_string(), e.into()))
+ }
+ None => {
+ debug!(
+ "no existing state file found for endpoint at {path_str}, creating empty state"
+ );
+ Ok(State::default())
+ }
+ }
+ }
+
+ /// Persist the state at `path`.
+ ///
+ /// Create the state file's parent directories with `dir_options` and the state file itself
+ /// with `file_options`.
+ ///
+ /// # Errors
+ /// An [`Error`] is returned if serialization of the state object, or the final write, fail.
+ pub fn save<P: AsRef<Path>>(
+ self,
+ path: P,
+ file_options: proxmox_sys::fs::CreateOptions,
+ dir_options: proxmox_sys::fs::CreateOptions,
+ ) -> Result<(), Error> {
+ let path_str = path.as_ref().to_string_lossy();
+
+ debug!("attempting to persist state at {path_str}");
+
+ if let Some(parent) = path.as_ref().parent() {
+ proxmox_sys::fs::create_path(parent, Some(dir_options), Some(dir_options))
+ .map_err(|e| Error::StatePersistence(path_str.to_string(), e.into()))?;
+ }
+
+ let s = serde_json::to_string_pretty(&self)
+ .map_err(|e| Error::StatePersistence(path_str.to_string(), e.into()))?;
+
+ proxmox_sys::fs::replace_file(&path, s.as_bytes(), file_options, false)
+ .map_err(|e| Error::StatePersistence(path_str.to_string(), e.into()))
+ }
+
+ /// Delete the state file at `path`.
+ ///
+ /// Errors are logged but not propagated.
+ pub fn delete<P: AsRef<Path>>(path: P) {
+ if let Err(e) = std::fs::remove_file(&path)
+ && e.kind() != std::io::ErrorKind::NotFound
+ {
+ let path_str = path.as_ref().to_string_lossy();
+ error!("could not delete state file at {path_str}: {e}");
+ }
+ }
+
+ /// Set `last_refreshed`.
+ pub fn set_last_refreshed(mut self, last_refreshed: i64) -> Self {
+ self.last_refreshed = last_refreshed;
+ self
+ }
+
+ /// Set `oauth2_refresh_token`.
+ pub fn set_oauth2_refresh_token(mut self, oauth2_refresh_token: Option<String>) -> Self {
+ self.oauth2_refresh_token = oauth2_refresh_token;
+ self
+ }
+}
+
/// Implements `oauth2`'s `SyncHttpClient` trait.
///
/// This allows `oauth2` to use `proxmox-http` as a backend for OAuth2 requests.
diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs
index 12d21edc..297daf49 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
next prev parent reply other threads:[~2026-06-02 8:56 UTC|newest]
Thread overview: 28+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-02 8:53 [PATCH docs/manager/proxmox{,-perl-rs,-widget-toolkit,-backup} v6 00/27] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
2026-06-02 8:53 ` [PATCH proxmox v6 01/27] add oauth2 and ureq to workspace dependencies Arthur Bied-Charreton
2026-06-02 8:53 ` [PATCH proxmox v6 02/27] notify: smtp: introduce xoauth2 module Arthur Bied-Charreton
2026-06-02 8:53 ` Arthur Bied-Charreton [this message]
2026-06-02 8:53 ` [PATCH proxmox v6 04/27] notify: smtp: factor out transport building logic Arthur Bied-Charreton
2026-06-02 8:53 ` [PATCH proxmox v6 05/27] notify: smtp: update API with OAuth2 parameters Arthur Bied-Charreton
2026-06-02 8:53 ` [PATCH proxmox v6 06/27] notify: smtp: add API to exchange authorization code for refresh token Arthur Bied-Charreton
2026-06-02 8:53 ` [PATCH proxmox v6 07/27] notify: smtp: infer auth method for backwards compatibility Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox v6 08/27] notify: smtp: add state handling logic Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox v6 09/27] notify: smtp: add XOAUTH2 authentication support Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-perl-rs v6 10/27] pve-rs: notify: smtp: add OAuth2 parameters to bindings Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-perl-rs v6 11/27] pve-rs: notify: add binding for triggering state refresh Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-perl-rs v6 12/27] pve-rs: notify: add binding for initial OAuth2 refresh token exchange Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-widget-toolkit v6 13/27] utils: add OAuth2 flow handlers Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-widget-toolkit v6 14/27] utils: oauth2: add callback handler Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-widget-toolkit v6 15/27] notifications: add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH pve-manager v6 16/27] notifications: smtp: api: add XOAUTH2 parameters Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH pve-manager v6 17/27] notifications: add endpoint for initial OAuth2 refresh token exchange Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH pve-manager v6 18/27] pveupdate: refresh notification targets' OAuth2 state Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH pve-manager v6 19/27] login: handle OAuth2 callback Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH pve-manager v6 20/27] fix #7238: notifications: smtp: add XOAUTH2 support Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-backup v6 21/27] notifications: add XOAUTH2 parameters to endpoints Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-backup v6 22/27] notifications: add endpoint for initial OAuth2 refresh token exchange Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-backup v6 23/27] login: handle OAuth2 callback Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-backup v6 24/27] fix #7238: notifications: smtp: add XOAUTH2 support Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-backup v6 25/27] daily-update: refresh OAuth2 state for SMTP notification endpoints Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH proxmox-backup v6 26/27] notifications: add OAuth2 section to SMTP targets docs Arthur Bied-Charreton
2026-06-02 8:54 ` [PATCH pve-docs v6 27/27] " Arthur Bied-Charreton
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260602085419.271767-4-a.bied-charreton@proxmox.com \
--to=a.bied-charreton@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox