all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v2] Add notifications backend
Date: Thu,  9 Apr 2026 06:57:15 +0200	[thread overview]
Message-ID: <20260409045819.19858-1-a.bied-charreton@proxmox.com> (raw)

Ideally, the whole router should be in proxmox-notify. However, after
discussing this quite extensively off-list with Lukas, we came to the
conclusion that doing so would require a large refactor that is
especially tricky given the fact that PVE would still need a different
entry point into proxmox-notify than PBS and PDM, since it defines its
router on the Perl side.

For this reason, factoring the routing logic out into proxmox-notify is
offset to a future where PVE also has a Rust-based router, and for now
it is just copied over from PBS (commit: fbf8d1b2) with the adaptations
described below.

The `proxmox_notify::context::Context` trait implementation is added to
PDM directly instead of creating a pdm.rs module in proxmox-notify to
avoid adding even more product-specific logic to it. It uses
`proxmox-access-control`, which is pulled in by PDM anyway, for getting
the user config instead of reading the file directly.

The `matcher-fields` and `matcher-field-values` endpoints currently
return empty vecs, they will be updated in a future commit introducing
actual notifications. The same applies for the notification templates.

The endpoints are made unprotected and the configuration files
read/writable by `www-data`, like for `remotes.cfg`.

Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
 Cargo.toml                                    |   1 +
 Makefile                                      |   3 +-
 debian/proxmox-datacenter-manager.install     |   2 +
 defines.mk                                    |   1 +
 lib/pdm-config/Cargo.toml                     |   1 +
 lib/pdm-config/src/lib.rs                     |   1 +
 lib/pdm-config/src/notifications.rs           |  39 ++++
 server/Cargo.toml                             |   1 +
 server/src/api/config/mod.rs                  |   2 +
 server/src/api/config/notifications/gotify.rs | 185 +++++++++++++++++
 .../src/api/config/notifications/matchers.rs  | 165 ++++++++++++++++
 server/src/api/config/notifications/mod.rs    | 102 ++++++++++
 .../src/api/config/notifications/sendmail.rs  | 173 ++++++++++++++++
 server/src/api/config/notifications/smtp.rs   | 186 ++++++++++++++++++
 .../src/api/config/notifications/targets.rs   |  61 ++++++
 .../src/api/config/notifications/webhook.rs   | 170 ++++++++++++++++
 server/src/context.rs                         |   2 +
 server/src/lib.rs                             |   1 +
 server/src/notifications/mod.rs               | 115 +++++++++++
 templates/Makefile                            |  14 ++
 templates/default/test-body.txt.hbs           |   1 +
 templates/default/test-subject.txt.hbs        |   1 +
 22 files changed, 1226 insertions(+), 1 deletion(-)
 create mode 100644 lib/pdm-config/src/notifications.rs
 create mode 100644 server/src/api/config/notifications/gotify.rs
 create mode 100644 server/src/api/config/notifications/matchers.rs
 create mode 100644 server/src/api/config/notifications/mod.rs
 create mode 100644 server/src/api/config/notifications/sendmail.rs
 create mode 100644 server/src/api/config/notifications/smtp.rs
 create mode 100644 server/src/api/config/notifications/targets.rs
 create mode 100644 server/src/api/config/notifications/webhook.rs
 create mode 100644 server/src/notifications/mod.rs
 create mode 100644 templates/Makefile
 create mode 100644 templates/default/test-body.txt.hbs
 create mode 100644 templates/default/test-subject.txt.hbs

diff --git a/Cargo.toml b/Cargo.toml
index ec2aa3d..918e831 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -47,6 +47,7 @@ proxmox-ldap = { version = "1.1", features = ["sync"] }
 proxmox-lang = "1.1"
 proxmox-log = "1"
 proxmox-login = "1.0.2"
+proxmox-notify = "1"
 proxmox-rest-server = "1"
 # some use "cli", some use "cli" and "server", pbs-config uses nothing
 proxmox-router = { version = "3.0.0", default-features = false }
diff --git a/Makefile b/Makefile
index 8e1cd13..48327c9 100644
--- a/Makefile
+++ b/Makefile
@@ -79,6 +79,7 @@ install: $(COMPILED_BINS) $(SHELL_COMPLETION_FILES)
 	    install -m644 $(COMPLETION_DIR)/$(i) $(DESTDIR)$(ZSHCOMPDIR)/ ;)
 	make -C services install
 	$(MAKE) -C docs install
+	$(MAKE) -C templates install
 
 $(COMPILED_BINS) $(COMPILEDIR)/docgen &:
 	$(CARGO) build $(CARGO_BUILD_ARGS)
@@ -99,7 +100,7 @@ cargo-build:
 $(BUILDDIR):
 	rm -rf $@ $@.tmp
 	mkdir $@.tmp
-	cp -a debian/ server/ services/ cli/ lib/ docs/ ui/ defines.mk Makefile Cargo.toml $@.tmp
+	cp -a debian/ server/ services/ cli/ lib/ docs/ ui/ templates/ defines.mk Makefile Cargo.toml $@.tmp
 	echo "git clone git://git.proxmox.com/git/$(PACKAGE).git\\ngit checkout $$(git rev-parse HEAD)" \
 	    > $@.tmp/debian/SOURCE
 	mv $@.tmp $@
diff --git a/debian/proxmox-datacenter-manager.install b/debian/proxmox-datacenter-manager.install
index fad3f4a..fb2b1f1 100644
--- a/debian/proxmox-datacenter-manager.install
+++ b/debian/proxmox-datacenter-manager.install
@@ -20,3 +20,5 @@ usr/share/man/man5/remotes.cfg.5
 usr/share/man/man5/views.cfg.5
 usr/share/zsh/vendor-completions/_pdmAtoB
 usr/share/zsh/vendor-completions/_proxmox-datacenter-manager-admin
+usr/share/proxmox-datacenter-manager/templates/default/test-body.txt.hbs
+usr/share/proxmox-datacenter-manager/templates/default/test-subject.txt.hbs
diff --git a/defines.mk b/defines.mk
index 1b9c1a8..111923f 100644
--- a/defines.mk
+++ b/defines.mk
@@ -3,6 +3,7 @@ BINDIR = $(PREFIX)/bin
 SBINDIR = $(PREFIX)/sbin
 LIBDIR = $(PREFIX)/lib
 LIBEXECDIR = $(PREFIX)/libexec
+DATAROOTDIR = $(PREFIX)/share
 BASHCOMPDIR = $(PREFIX)/share/bash-completion/completions
 ZSHCOMPDIR = $(PREFIX)/share/zsh/vendor-completions
 MAN1DIR = $(PREFIX)/share/man/man1
diff --git a/lib/pdm-config/Cargo.toml b/lib/pdm-config/Cargo.toml
index d39c2ad..b6f3739 100644
--- a/lib/pdm-config/Cargo.toml
+++ b/lib/pdm-config/Cargo.toml
@@ -23,5 +23,6 @@ proxmox-shared-memory.workspace = true
 proxmox-simple-config.workspace = true
 proxmox-sys = { workspace = true, features = [ "acl", "crypt", "timer" ] }
 proxmox-acme-api.workspace = true
+proxmox-notify.workspace = true
 pdm-api-types.workspace = true
 pdm-buildcfg.workspace = true
diff --git a/lib/pdm-config/src/lib.rs b/lib/pdm-config/src/lib.rs
index 4c49054..03dc247 100644
--- a/lib/pdm-config/src/lib.rs
+++ b/lib/pdm-config/src/lib.rs
@@ -5,6 +5,7 @@ pub use pdm_buildcfg::{BACKUP_GROUP_NAME, BACKUP_USER_NAME};
 pub mod certificate_config;
 pub mod domains;
 pub mod node;
+pub mod notifications;
 pub mod remotes;
 pub mod setup;
 pub mod views;
diff --git a/lib/pdm-config/src/notifications.rs b/lib/pdm-config/src/notifications.rs
new file mode 100644
index 0000000..fd6ec79
--- /dev/null
+++ b/lib/pdm-config/src/notifications.rs
@@ -0,0 +1,39 @@
+use anyhow::Error;
+
+use proxmox_notify::Config;
+
+use pdm_buildcfg::configdir;
+use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
+use proxmox_sys::fs::file_read_optional_string;
+
+/// Configuration file location for notification targets/matchers.
+pub const NOTIFICATION_CONFIG_PATH: &str = configdir!("/notifications.cfg");
+
+/// Private configuration file location for secrets - only readable by `root`.
+pub const NOTIFICATION_PRIV_CONFIG_PATH: &str = configdir!("/notifications-priv.cfg");
+
+/// Lockfile to prevent concurrent write access.
+pub const NOTIFICATION_LOCK_FILE: &str = configdir!("/.notifications.lck");
+
+/// Get exclusive lock for `notifications.cfg`.
+pub fn lock_config() -> Result<ApiLockGuard, Error> {
+    open_api_lockfile(NOTIFICATION_LOCK_FILE, None, true)
+}
+
+/// Load notification config.
+pub fn config() -> Result<Config, Error> {
+    let content = file_read_optional_string(NOTIFICATION_CONFIG_PATH)?.unwrap_or_default();
+
+    let priv_content =
+        file_read_optional_string(NOTIFICATION_PRIV_CONFIG_PATH)?.unwrap_or_default();
+
+    Ok(Config::new(&content, &priv_content)?)
+}
+
+/// Save notification config.
+pub fn save_config(config: Config) -> Result<(), Error> {
+    let (cfg, priv_cfg) = config.write()?;
+    replace_config(NOTIFICATION_CONFIG_PATH, cfg.as_bytes())?;
+    replace_config(NOTIFICATION_PRIV_CONFIG_PATH, priv_cfg.as_bytes())?;
+    Ok(())
+}
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 6969549..a4f7bbd 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -46,6 +46,7 @@ proxmox-lang.workspace = true
 proxmox-ldap.workspace = true
 proxmox-log.workspace = true
 proxmox-login.workspace = true
+proxmox-notify.workspace = true
 proxmox-openid.workspace = true
 proxmox-rest-server = { workspace = true, features = [ "templates" ] }
 proxmox-router = { workspace = true, features = [ "cli", "server"] }
diff --git a/server/src/api/config/mod.rs b/server/src/api/config/mod.rs
index 8f646c1..a465219 100644
--- a/server/src/api/config/mod.rs
+++ b/server/src/api/config/mod.rs
@@ -6,6 +6,7 @@ pub mod access;
 pub mod acme;
 pub mod certificate;
 pub mod notes;
+pub mod notifications;
 pub mod views;
 
 #[sortable]
@@ -14,6 +15,7 @@ const SUBDIRS: SubdirMap = &sorted!([
     ("acme", &acme::ROUTER),
     ("certificate", &certificate::ROUTER),
     ("notes", &notes::ROUTER),
+    ("notifications", &notifications::ROUTER),
     ("views", &views::ROUTER)
 ]);
 
diff --git a/server/src/api/config/notifications/gotify.rs b/server/src/api/config/notifications/gotify.rs
new file mode 100644
index 0000000..a281245
--- /dev/null
+++ b/server/src/api/config/notifications/gotify.rs
@@ -0,0 +1,185 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_notify::endpoints::gotify::{
+    DeleteableGotifyProperty, GotifyConfig, GotifyConfigUpdater, GotifyPrivateConfig,
+    GotifyPrivateConfigUpdater,
+};
+use proxmox_notify::schema::ENTITY_NAME_SCHEMA;
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::api;
+
+use pbs_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA};
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of gotify endpoints.",
+        type: Array,
+        items: { type: GotifyConfig },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// List all gotify endpoints.
+pub fn list_endpoints(
+    _param: Value,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<GotifyConfig>, Error> {
+    let config = pdm_config::notifications::config()?;
+
+    let endpoints = proxmox_notify::api::gotify::get_endpoints(&config)?;
+
+    Ok(endpoints)
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    returns: { type: GotifyConfig },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Get a gotify endpoint.
+pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<GotifyConfig, Error> {
+    let config = pdm_config::notifications::config()?;
+    let endpoint = proxmox_notify::api::gotify::get_endpoint(&config, &name)?;
+
+    rpcenv["digest"] = hex::encode(config.digest()).into();
+
+    Ok(endpoint)
+}
+
+#[api(
+    input: {
+        properties: {
+            endpoint: {
+                type: GotifyConfig,
+                flatten: true,
+            },
+            token: {
+                description: "Authentication token",
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Add a new gotify endpoint.
+pub fn add_endpoint(
+    endpoint: GotifyConfig,
+    token: String,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    let private_endpoint_config = GotifyPrivateConfig {
+        name: endpoint.name.clone(),
+        token,
+    };
+
+    proxmox_notify::api::gotify::add_endpoint(&mut config, endpoint, private_endpoint_config)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+            updater: {
+                type: GotifyConfigUpdater,
+                flatten: true,
+            },
+            token: {
+                description: "Authentication token",
+                optional: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeleteableGotifyProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Update gotify endpoint.
+pub fn update_endpoint(
+    name: String,
+    updater: GotifyConfigUpdater,
+    token: Option<String>,
+    delete: Option<Vec<DeleteableGotifyProperty>>,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    let digest = digest.map(hex::decode).transpose()?;
+
+    proxmox_notify::api::gotify::update_endpoint(
+        &mut config,
+        &name,
+        updater,
+        GotifyPrivateConfigUpdater { token },
+        delete.as_deref(),
+        digest.as_deref(),
+    )?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Delete gotify endpoint.
+pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    proxmox_notify::api::gotify::delete_gotify_endpoint(&mut config, &name)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_GET_ENDPOINT)
+    .put(&API_METHOD_UPDATE_ENDPOINT)
+    .delete(&API_METHOD_DELETE_ENDPOINT);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_ENDPOINTS)
+    .post(&API_METHOD_ADD_ENDPOINT)
+    .match_all("name", &ITEM_ROUTER);
diff --git a/server/src/api/config/notifications/matchers.rs b/server/src/api/config/notifications/matchers.rs
new file mode 100644
index 0000000..9918305
--- /dev/null
+++ b/server/src/api/config/notifications/matchers.rs
@@ -0,0 +1,165 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_notify::matcher::{DeleteableMatcherProperty, MatcherConfig, MatcherConfigUpdater};
+use proxmox_notify::schema::ENTITY_NAME_SCHEMA;
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::api;
+
+use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA};
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of matchers.",
+        type: Array,
+        items: { type: MatcherConfig },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// List all notification matchers.
+pub fn list_matchers(
+    _param: Value,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<MatcherConfig>, Error> {
+    let config = pdm_config::notifications::config()?;
+
+    let matchers = proxmox_notify::api::matcher::get_matchers(&config)?;
+
+    Ok(matchers)
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    returns: { type: MatcherConfig },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Get a notification matcher.
+pub fn get_matcher(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<MatcherConfig, Error> {
+    let config = pdm_config::notifications::config()?;
+    let matcher = proxmox_notify::api::matcher::get_matcher(&config, &name)?;
+
+    rpcenv["digest"] = hex::encode(config.digest()).into();
+
+    Ok(matcher)
+}
+
+#[api(
+    input: {
+        properties: {
+            matcher: {
+                type: MatcherConfig,
+                flatten: true,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Add a new notification matcher.
+pub fn add_matcher(matcher: MatcherConfig, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+
+    proxmox_notify::api::matcher::add_matcher(&mut config, matcher)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+            updater: {
+                type: MatcherConfigUpdater,
+                flatten: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeleteableMatcherProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Update notification matcher.
+pub fn update_matcher(
+    name: String,
+    updater: MatcherConfigUpdater,
+    delete: Option<Vec<DeleteableMatcherProperty>>,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    let digest = digest.map(hex::decode).transpose()?;
+
+    proxmox_notify::api::matcher::update_matcher(
+        &mut config,
+        &name,
+        updater,
+        delete.as_deref(),
+        digest.as_deref(),
+    )?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Delete notification matcher.
+pub fn delete_matcher(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    proxmox_notify::api::matcher::delete_matcher(&mut config, &name)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_GET_MATCHER)
+    .put(&API_METHOD_UPDATE_MATCHER)
+    .delete(&API_METHOD_DELETE_MATCHER);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_MATCHERS)
+    .post(&API_METHOD_ADD_MATCHER)
+    .match_all("name", &ITEM_ROUTER);
diff --git a/server/src/api/config/notifications/mod.rs b/server/src/api/config/notifications/mod.rs
new file mode 100644
index 0000000..5730116
--- /dev/null
+++ b/server/src/api/config/notifications/mod.rs
@@ -0,0 +1,102 @@
+use anyhow::Error;
+use pdm_api_types::PRIV_SYS_AUDIT;
+use proxmox_router::{
+    list_subdirs_api_method, ApiMethod, Permission, Router, RpcEnvironment, SubdirMap,
+};
+use proxmox_schema::api;
+use proxmox_sortable_macro::sortable;
+use serde::Serialize;
+use serde_json::Value;
+
+pub mod gotify;
+pub mod matchers;
+pub mod sendmail;
+pub mod smtp;
+pub mod targets;
+pub mod webhook;
+
+#[sortable]
+const SUBDIRS: SubdirMap = &sorted!([
+    ("endpoints", &ENDPOINT_ROUTER),
+    ("matcher-fields", &FIELD_ROUTER),
+    ("matcher-field-values", &VALUE_ROUTER),
+    ("targets", &targets::ROUTER),
+    ("matchers", &matchers::ROUTER),
+]);
+
+pub const ROUTER: Router = Router::new()
+    .get(&list_subdirs_api_method!(SUBDIRS))
+    .subdirs(SUBDIRS);
+
+#[sortable]
+const ENDPOINT_SUBDIRS: SubdirMap = &sorted!([
+    ("gotify", &gotify::ROUTER),
+    ("sendmail", &sendmail::ROUTER),
+    ("smtp", &smtp::ROUTER),
+    ("webhook", &webhook::ROUTER),
+]);
+
+const ENDPOINT_ROUTER: Router = Router::new()
+    .get(&list_subdirs_api_method!(ENDPOINT_SUBDIRS))
+    .subdirs(ENDPOINT_SUBDIRS);
+
+const FIELD_ROUTER: Router = Router::new().get(&API_METHOD_GET_FIELDS);
+const VALUE_ROUTER: Router = Router::new().get(&API_METHOD_GET_VALUES);
+
+#[api]
+#[derive(Serialize)]
+/// A matchable field
+pub struct MatchableField {
+    /// Name of the field
+    name: String,
+}
+
+#[api]
+#[derive(Serialize)]
+/// A matchable metadata field value
+pub struct MatchableValue {
+    /// Field this value belongs to.
+    field: String,
+    /// Notification metadata value known by the system.
+    value: String,
+    /// Additional comment for this value.
+    comment: Option<String>,
+}
+
+#[api(
+    input: {
+        properties: {}
+    },
+    returns: {
+        description: "List of known metadata fields.",
+        type: Array,
+        items: {type: MatchableField},
+    },
+    access: {permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false)},
+)]
+/// Get all known metadata fields.
+pub fn get_fields() -> Result<Vec<MatchableField>, Error> {
+    Ok(vec![])
+}
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of known metadata field values.",
+        type: Array,
+        items: { type: MatchableValue },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// List all known, matchable metadata field values
+pub fn get_values(
+    _param: Value,
+    _info: &ApiMethod,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<MatchableValue>, Error> {
+    Ok(vec![])
+}
diff --git a/server/src/api/config/notifications/sendmail.rs b/server/src/api/config/notifications/sendmail.rs
new file mode 100644
index 0000000..66c9656
--- /dev/null
+++ b/server/src/api/config/notifications/sendmail.rs
@@ -0,0 +1,173 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_notify::endpoints::sendmail::{
+    DeleteableSendmailProperty, SendmailConfig, SendmailConfigUpdater,
+};
+use proxmox_notify::schema::ENTITY_NAME_SCHEMA;
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::api;
+
+use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA};
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of sendmail endpoints.",
+        type: Array,
+        items: { type: SendmailConfig },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// List all sendmail endpoints.
+pub fn list_endpoints(
+    _param: Value,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<SendmailConfig>, Error> {
+    let config = pdm_config::notifications::config()?;
+
+    let endpoints = proxmox_notify::api::sendmail::get_endpoints(&config)?;
+
+    Ok(endpoints)
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    returns: { type: SendmailConfig },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Get a sendmail endpoint.
+pub fn get_endpoint(
+    name: String,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<SendmailConfig, Error> {
+    let config = pdm_config::notifications::config()?;
+    let endpoint = proxmox_notify::api::sendmail::get_endpoint(&config, &name)?;
+
+    rpcenv["digest"] = hex::encode(config.digest()).into();
+
+    Ok(endpoint)
+}
+
+#[api(
+    input: {
+        properties: {
+            endpoint: {
+                type: SendmailConfig,
+                flatten: true,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Add a new sendmail endpoint.
+pub fn add_endpoint(
+    endpoint: SendmailConfig,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+
+    proxmox_notify::api::sendmail::add_endpoint(&mut config, endpoint)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+            updater: {
+                type: SendmailConfigUpdater,
+                flatten: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeleteableSendmailProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Update sendmail endpoint.
+pub fn update_endpoint(
+    name: String,
+    updater: SendmailConfigUpdater,
+    delete: Option<Vec<DeleteableSendmailProperty>>,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    let digest = digest.map(hex::decode).transpose()?;
+
+    proxmox_notify::api::sendmail::update_endpoint(
+        &mut config,
+        &name,
+        updater,
+        delete.as_deref(),
+        digest.as_deref(),
+    )?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Delete sendmail endpoint.
+pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    proxmox_notify::api::sendmail::delete_endpoint(&mut config, &name)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_GET_ENDPOINT)
+    .put(&API_METHOD_UPDATE_ENDPOINT)
+    .delete(&API_METHOD_DELETE_ENDPOINT);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_ENDPOINTS)
+    .post(&API_METHOD_ADD_ENDPOINT)
+    .match_all("name", &ITEM_ROUTER);
diff --git a/server/src/api/config/notifications/smtp.rs b/server/src/api/config/notifications/smtp.rs
new file mode 100644
index 0000000..4a48256
--- /dev/null
+++ b/server/src/api/config/notifications/smtp.rs
@@ -0,0 +1,186 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_notify::endpoints::smtp::{
+    DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
+    SmtpPrivateConfigUpdater,
+};
+use proxmox_notify::schema::ENTITY_NAME_SCHEMA;
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::api;
+
+use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA};
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of smtp endpoints.",
+        type: Array,
+        items: { type: SmtpConfig },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// List all smtp endpoints.
+pub fn list_endpoints(
+    _param: Value,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<SmtpConfig>, Error> {
+    let config = pdm_config::notifications::config()?;
+
+    let endpoints = proxmox_notify::api::smtp::get_endpoints(&config)?;
+
+    Ok(endpoints)
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    returns: { type: SmtpConfig },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Get a smtp endpoint.
+pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<SmtpConfig, Error> {
+    let config = pdm_config::notifications::config()?;
+    let endpoint = proxmox_notify::api::smtp::get_endpoint(&config, &name)?;
+
+    rpcenv["digest"] = hex::encode(config.digest()).into();
+
+    Ok(endpoint)
+}
+
+#[api(
+    input: {
+        properties: {
+            endpoint: {
+                type: SmtpConfig,
+                flatten: true,
+            },
+            password: {
+                optional: true,
+                description: "SMTP authentication password"
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Add a new smtp endpoint.
+pub fn add_endpoint(
+    endpoint: SmtpConfig,
+    password: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    let private_endpoint_config = SmtpPrivateConfig {
+        name: endpoint.name.clone(),
+        password,
+    };
+
+    proxmox_notify::api::smtp::add_endpoint(&mut config, endpoint, private_endpoint_config)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+            updater: {
+                type: SmtpConfigUpdater,
+                flatten: true,
+            },
+            password: {
+                description: "SMTP authentication password",
+                optional: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeleteableSmtpProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Update smtp endpoint.
+pub fn update_endpoint(
+    name: String,
+    updater: SmtpConfigUpdater,
+    password: Option<String>,
+    delete: Option<Vec<DeleteableSmtpProperty>>,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    let digest = digest.map(hex::decode).transpose()?;
+
+    proxmox_notify::api::smtp::update_endpoint(
+        &mut config,
+        &name,
+        updater,
+        SmtpPrivateConfigUpdater { password },
+        delete.as_deref(),
+        digest.as_deref(),
+    )?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Delete smtp endpoint.
+pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    proxmox_notify::api::smtp::delete_endpoint(&mut config, &name)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_GET_ENDPOINT)
+    .put(&API_METHOD_UPDATE_ENDPOINT)
+    .delete(&API_METHOD_DELETE_ENDPOINT);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_ENDPOINTS)
+    .post(&API_METHOD_ADD_ENDPOINT)
+    .match_all("name", &ITEM_ROUTER);
diff --git a/server/src/api/config/notifications/targets.rs b/server/src/api/config/notifications/targets.rs
new file mode 100644
index 0000000..b2655fb
--- /dev/null
+++ b/server/src/api/config/notifications/targets.rs
@@ -0,0 +1,61 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_notify::api::Target;
+use proxmox_notify::schema::ENTITY_NAME_SCHEMA;
+use proxmox_router::{list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap};
+use proxmox_schema::api;
+use proxmox_sortable_macro::sortable;
+
+use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY};
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of all entities which can be used as notification targets.",
+        type: Array,
+        items: { type: Target },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// List all notification targets.
+pub fn list_targets(_param: Value, _rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<Target>, Error> {
+    let config = pdm_config::notifications::config()?;
+    let targets = proxmox_notify::api::get_targets(&config)?;
+
+    Ok(targets)
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+        }
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Test a given notification target.
+pub fn test_target(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    let config = pdm_config::notifications::config()?;
+    proxmox_notify::api::common::test_target(&config, &name)?;
+    Ok(())
+}
+
+#[sortable]
+const SUBDIRS: SubdirMap = &sorted!([("test", &TEST_ROUTER),]);
+const TEST_ROUTER: Router = Router::new().post(&API_METHOD_TEST_TARGET);
+const ITEM_ROUTER: Router = Router::new()
+    .get(&list_subdirs_api_method!(SUBDIRS))
+    .subdirs(SUBDIRS);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_TARGETS)
+    .match_all("name", &ITEM_ROUTER);
diff --git a/server/src/api/config/notifications/webhook.rs b/server/src/api/config/notifications/webhook.rs
new file mode 100644
index 0000000..751086f
--- /dev/null
+++ b/server/src/api/config/notifications/webhook.rs
@@ -0,0 +1,170 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_notify::endpoints::webhook::{
+    DeleteableWebhookProperty, WebhookConfig, WebhookConfigUpdater,
+};
+use proxmox_notify::schema::ENTITY_NAME_SCHEMA;
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::api;
+
+use pdm_api_types::{PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA};
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of webhook endpoints.",
+        type: Array,
+        items: { type: WebhookConfig },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// List all webhook endpoints.
+pub fn list_endpoints(
+    _param: Value,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<WebhookConfig>, Error> {
+    let config = pdm_config::notifications::config()?;
+
+    let endpoints = proxmox_notify::api::webhook::get_endpoints(&config)?;
+
+    Ok(endpoints)
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    returns: { type: WebhookConfig },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Get a webhook endpoint.
+pub fn get_endpoint(name: String, rpcenv: &mut dyn RpcEnvironment) -> Result<WebhookConfig, Error> {
+    let config = pdm_config::notifications::config()?;
+    let endpoint = proxmox_notify::api::webhook::get_endpoint(&config, &name)?;
+
+    rpcenv["digest"] = hex::encode(config.digest()).into();
+
+    Ok(endpoint)
+}
+
+#[api(
+    input: {
+        properties: {
+            endpoint: {
+                type: WebhookConfig,
+                flatten: true,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Add a new webhook endpoint.
+pub fn add_endpoint(
+    endpoint: WebhookConfig,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+
+    proxmox_notify::api::webhook::add_endpoint(&mut config, endpoint)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            },
+            updater: {
+                type: WebhookConfigUpdater,
+                flatten: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeleteableWebhookProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Update webhook endpoint.
+pub fn update_endpoint(
+    name: String,
+    updater: WebhookConfigUpdater,
+    delete: Option<Vec<DeleteableWebhookProperty>>,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    let digest = digest.map(hex::decode).transpose()?;
+
+    proxmox_notify::api::webhook::update_endpoint(
+        &mut config,
+        &name,
+        updater,
+        delete.as_deref(),
+        digest.as_deref(),
+    )?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: ENTITY_NAME_SCHEMA,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["system", "notifications"], PRIV_SYS_MODIFY, false),
+    },
+)]
+/// Delete webhook endpoint.
+pub fn delete_endpoint(name: String, _rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    let _lock = pdm_config::notifications::lock_config()?;
+    let mut config = pdm_config::notifications::config()?;
+    proxmox_notify::api::webhook::delete_endpoint(&mut config, &name)?;
+
+    pdm_config::notifications::save_config(config)?;
+    Ok(())
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_GET_ENDPOINT)
+    .put(&API_METHOD_UPDATE_ENDPOINT)
+    .delete(&API_METHOD_DELETE_ENDPOINT);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_ENDPOINTS)
+    .post(&API_METHOD_ADD_ENDPOINT)
+    .match_all("name", &ITEM_ROUTER);
diff --git a/server/src/context.rs b/server/src/context.rs
index c5da0af..acff3a8 100644
--- a/server/src/context.rs
+++ b/server/src/context.rs
@@ -38,5 +38,7 @@ pub fn init() -> Result<(), Error> {
         default_remote_setup();
     }
 
+    crate::notifications::init();
+
     Ok(())
 }
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 5ed10d6..9723bc4 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -7,6 +7,7 @@ pub mod context;
 pub mod env;
 pub mod jobstate;
 pub mod metric_collection;
+pub mod notifications;
 pub mod parallel_fetcher;
 pub mod remote_cache;
 pub mod remote_tasks;
diff --git a/server/src/notifications/mod.rs b/server/src/notifications/mod.rs
new file mode 100644
index 0000000..e38584b
--- /dev/null
+++ b/server/src/notifications/mod.rs
@@ -0,0 +1,115 @@
+use std::path::Path;
+
+use proxmox_access_control::types::User;
+use proxmox_notify::context::Context;
+use proxmox_notify::renderer::TemplateSource;
+use proxmox_notify::Error;
+use tracing::error;
+
+use pdm_buildcfg::configdir;
+
+const PDM_NODE_CFG_FILENAME: &str = configdir!("/node.cfg");
+
+const DEFAULT_CONFIG: &str = "\
+sendmail: mail-to-root
+    comment Send mails to root@pam's email address
+    mailto-user root@pam
+
+
+matcher: default-matcher
+    mode all
+    target mail-to-root
+    comment Route all notifications to mail-to-root
+";
+
+fn attempt_file_read<P: AsRef<Path>>(path: P) -> Option<String> {
+    match proxmox_sys::fs::file_read_optional_string(path) {
+        Ok(contents) => contents,
+        Err(err) => {
+            error!("{err}");
+            None
+        }
+    }
+}
+
+fn lookup_datacenter_config_key(content: &str, key: &str) -> Option<String> {
+    let key_prefix = format!("{key}:");
+    let value = content
+        .lines()
+        .find_map(|line| line.strip_prefix(&key_prefix))?
+        .trim();
+
+    if value.is_empty() {
+        None
+    } else {
+        Some(value.to_string())
+    }
+}
+
+#[derive(Debug)]
+struct PDMContext;
+
+static PDM_CONTEXT: PDMContext = PDMContext;
+
+impl Context for PDMContext {
+    fn lookup_email_for_user(&self, user: &str) -> Option<String> {
+        let (config, _digest) = match proxmox_access_control::user::config() {
+            Ok(c) => c,
+            Err(err) => {
+                error!("failed to read user config: {err}");
+                return None;
+            }
+        };
+
+        match config.lookup::<User>("user", user) {
+            Ok(user) => user.email,
+            Err(_) => None,
+        }
+    }
+
+    fn default_sendmail_author(&self) -> String {
+        format!("Proxmox Datacenter Manager - {}", proxmox_sys::nodename())
+    }
+
+    fn default_sendmail_from(&self) -> String {
+        let content = attempt_file_read(PDM_NODE_CFG_FILENAME);
+        content
+            .and_then(|content| lookup_datacenter_config_key(&content, "email-from"))
+            .unwrap_or_else(|| String::from("root"))
+    }
+
+    fn http_proxy_config(&self) -> Option<String> {
+        let content = attempt_file_read(PDM_NODE_CFG_FILENAME);
+        content.and_then(|content| lookup_datacenter_config_key(&content, "http-proxy"))
+    }
+
+    fn default_config(&self) -> &'static str {
+        DEFAULT_CONFIG
+    }
+
+    fn lookup_template(
+        &self,
+        filename: &str,
+        namespace: Option<&str>,
+        source: TemplateSource,
+    ) -> Result<Option<String>, Error> {
+        let base = match source {
+            TemplateSource::Vendor => "/usr/share/proxmox-datacenter-manager/templates",
+            TemplateSource::Override => configdir!("/notification-templates"),
+        };
+
+        let path = Path::new(base)
+            .join(namespace.unwrap_or("default"))
+            .join(filename);
+
+        error!("{path:?}");
+
+        proxmox_sys::fs::file_read_optional_string(path)
+            .map_err(|err| Error::Generic(format!("could not load template: {err}")))
+    }
+}
+
+/// Initialize `proxmox-notify` by registering the PDM context.
+pub fn init() {
+    proxmox_notify::context::set_context(&PDM_CONTEXT);
+}
diff --git a/templates/Makefile b/templates/Makefile
new file mode 100644
index 0000000..294fa7b
--- /dev/null
+++ b/templates/Makefile
@@ -0,0 +1,14 @@
+include ../defines.mk
+
+NOTIFICATION_TEMPLATES=						\
+	default/test-body.txt.hbs				\
+	default/test-subject.txt.hbs			\
+
+all:
+
+clean:
+
+install:
+	install -dm755 $(DESTDIR)$(DATAROOTDIR)/proxmox-datacenter-manager/templates/default
+	$(foreach i,$(NOTIFICATION_TEMPLATES), \
+	    install -m644 $(i) $(DESTDIR)$(DATAROOTDIR)/proxmox-datacenter-manager/templates/$(i) ;)
diff --git a/templates/default/test-body.txt.hbs b/templates/default/test-body.txt.hbs
new file mode 100644
index 0000000..2445443
--- /dev/null
+++ b/templates/default/test-body.txt.hbs
@@ -0,0 +1 @@
+This is a test of the notification target '{{target}}'.
diff --git a/templates/default/test-subject.txt.hbs b/templates/default/test-subject.txt.hbs
new file mode 100644
index 0000000..cb8e132
--- /dev/null
+++ b/templates/default/test-subject.txt.hbs
@@ -0,0 +1 @@
+Test notification
-- 
2.47.3




             reply	other threads:[~2026-04-09  4:58 UTC|newest]

Thread overview: 8+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-09  4:57 Arthur Bied-Charreton [this message]
2026-04-09  9:20 ` Shannon Sterz
2026-04-09 10:18   ` Arthur Bied-Charreton
2026-04-09 11:13     ` Shannon Sterz
2026-04-09 12:07   ` Lukas Wagner
2026-04-09 12:39     ` Shannon Sterz
2026-04-09 18:26     ` Thomas Lamprecht
2026-04-10 12:00       ` 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=20260409045819.19858-1-a.bied-charreton@proxmox.com \
    --to=a.bied-charreton@proxmox.com \
    --cc=pdm-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal