notify: switch to file-based templating system

Instead of passing the template strings for subject and body when
constructing a notification, we pass only the name of a template.
When rendering the template, the name of the template is used to find
corresponding template files. For PVE, they are located at
/usr/share/proxmox-ve/templates/default. The `default` part is
the 'template namespace', which is a preparation for user-customizable
and/or translatable notifications.

Previously, the same template string was used to render HTML and
plaintext notifications. This was achieved by providing some template
helpers that 'abstract away' HTML/plaintext formatting. However,
in hindsight this turned out to be pretty finicky. Since the
current changes lay the foundations for user-customizable notification
templates, I ripped these abstractions out. Now there are simply two
templates, one for plaintext, one for HTML.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
Tested-by: Folke Gleumes <f.gleumes@proxmox.com>
Reviewed-by: Fiona Ebner <f.ebner@proxmox.com>
This commit is contained in:
Lukas Wagner 2024-04-19 16:17:04 +02:00 committed by Thomas Lamprecht
parent 42fb9ed26b
commit 1516cc26d2
13 changed files with 137 additions and 310 deletions

View File

@ -1,63 +0,0 @@
use proxmox_notify::renderer::{render_template, TemplateRenderer};
use proxmox_notify::Error;
use serde_json::json;
const TEMPLATE: &str = r#"
{{ heading-1 "Backup Report"}}
A backup job on host {{host}} was run.
{{ heading-2 "Guests"}}
{{ table table }}
The total size of all backups is {{human-bytes total-size}}.
The backup job took {{duration total-time}}.
{{ heading-2 "Logs"}}
{{ verbatim-monospaced logs}}
{{ heading-2 "Objects"}}
{{ object table }}
"#;
fn main() -> Result<(), Error> {
let properties = json!({
"host": "pali",
"logs": "100: starting backup\n100: backup failed",
"total-size": 1024 * 1024 + 2048 * 1024,
"total-time": 100,
"table": {
"schema": {
"columns": [
{
"label": "VMID",
"id": "vmid"
},
{
"label": "Size",
"id": "size",
"renderer": "human-bytes"
}
],
},
"data" : [
{
"vmid": 1001,
"size": "1048576"
},
{
"vmid": 1002,
"size": 2048 * 1024,
}
]
}
});
let output = render_template(TemplateRenderer::Html, TEMPLATE, &properties)?;
println!("{output}");
let output = render_template(TemplateRenderer::Plaintext, TEMPLATE, &properties)?;
println!("{output}");
Ok(())
}

View File

@ -1,6 +1,8 @@
use std::fmt::Debug;
use std::sync::Mutex;
use crate::Error;
#[cfg(any(feature = "pve-context", feature = "pbs-context"))]
pub mod common;
#[cfg(feature = "pbs-context")]
@ -20,8 +22,14 @@ pub trait Context: Send + Sync + Debug {
fn default_sendmail_from(&self) -> String;
/// Proxy configuration for the current node
fn http_proxy_config(&self) -> Option<String>;
// Return default config for built-in targets/matchers.
/// Return default config for built-in targets/matchers.
fn default_config(&self) -> &'static str;
/// Lookup a template in a certain (optional) namespace
fn lookup_template(
&self,
filename: &str,
namespace: Option<&str>,
) -> Result<Option<String>, Error>;
}
#[cfg(not(test))]

View File

@ -1,9 +1,11 @@
use serde::Deserialize;
use std::path::Path;
use proxmox_schema::{ObjectSchema, Schema, StringSchema};
use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
use crate::context::{common, Context};
use crate::Error;
const PBS_USER_CFG_FILENAME: &str = "/etc/proxmox-backup/user.cfg";
const PBS_NODE_CFG_FILENAME: &str = "/etc/proxmox-backup/node.cfg";
@ -98,6 +100,20 @@ impl Context for PBSContext {
fn default_config(&self) -> &'static str {
return DEFAULT_CONFIG;
}
fn lookup_template(
&self,
filename: &str,
namespace: Option<&str>,
) -> Result<Option<String>, Error> {
let path = Path::new("/usr/share/proxmox-backup/templates")
.join(namespace.unwrap_or("default"))
.join(filename);
let template_string = proxmox_sys::fs::file_read_optional_string(path)
.map_err(|err| Error::Generic(format!("could not load template: {err}")))?;
Ok(template_string)
}
}
#[cfg(test)]

View File

@ -1,4 +1,6 @@
use crate::context::{common, Context};
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| {
@ -51,6 +53,19 @@ impl Context for PVEContext {
fn default_config(&self) -> &'static str {
return DEFAULT_CONFIG;
}
fn lookup_template(
&self,
filename: &str,
namespace: Option<&str>,
) -> Result<Option<String>, Error> {
let path = Path::new("/usr/share/pve-manager/templates")
.join(namespace.unwrap_or("default"))
.join(filename);
let template_string = proxmox_sys::fs::file_read_optional_string(path)
.map_err(|err| Error::Generic(format!("could not load template: {err}")))?;
Ok(template_string)
}
}
pub static PVE_CONTEXT: PVEContext = PVEContext;

View File

@ -1,4 +1,5 @@
use crate::context::Context;
use crate::Error;
#[derive(Debug)]
pub struct TestContext;
@ -23,4 +24,12 @@ impl Context for TestContext {
fn default_config(&self) -> &'static str {
""
}
fn lookup_template(
&self,
_filename: &str,
_namespace: Option<&str>,
) -> Result<Option<String>, Error> {
Ok(Some(String::new()))
}
}

View File

@ -9,7 +9,7 @@ use proxmox_schema::api_types::COMMENT_SCHEMA;
use proxmox_schema::{api, Updater};
use crate::context::context;
use crate::renderer::TemplateRenderer;
use crate::renderer::TemplateType;
use crate::schema::ENTITY_NAME_SCHEMA;
use crate::{renderer, Content, Endpoint, Error, Notification, Origin, Severity};
@ -92,14 +92,13 @@ impl Endpoint for GotifyEndpoint {
fn send(&self, notification: &Notification) -> Result<(), Error> {
let (title, message) = match &notification.content {
Content::Template {
title_template,
body_template,
template_name,
data,
} => {
let rendered_title =
renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
renderer::render_template(TemplateType::Subject, template_name, data)?;
let rendered_message =
renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
renderer::render_template(TemplateType::PlaintextBody, template_name, data)?;
(rendered_title, rendered_message)
}

View File

@ -3,9 +3,9 @@ use serde::{Deserialize, Serialize};
use proxmox_schema::api_types::COMMENT_SCHEMA;
use proxmox_schema::{api, Updater};
use crate::context::context;
use crate::context;
use crate::endpoints::common::mail;
use crate::renderer::TemplateRenderer;
use crate::renderer::TemplateType;
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
use crate::{renderer, Content, Endpoint, Error, Notification, Origin};
@ -103,16 +103,15 @@ impl Endpoint for SendmailEndpoint {
match &notification.content {
Content::Template {
title_template,
body_template,
template_name,
data,
} => {
let subject =
renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
renderer::render_template(TemplateType::Subject, template_name, data)?;
let html_part =
renderer::render_template(TemplateRenderer::Html, body_template, data)?;
renderer::render_template(TemplateType::HtmlBody, template_name, data)?;
let text_part =
renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
renderer::render_template(TemplateType::PlaintextBody, template_name, data)?;
let author = self
.config

View File

@ -11,7 +11,7 @@ use proxmox_schema::{api, Updater};
use crate::context::context;
use crate::endpoints::common::mail;
use crate::renderer::TemplateRenderer;
use crate::renderer::TemplateType;
use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
use crate::{renderer, Content, Endpoint, Error, Notification, Origin};
@ -202,16 +202,15 @@ impl Endpoint for SmtpEndpoint {
let mut email = match &notification.content {
Content::Template {
title_template,
body_template,
template_name,
data,
} => {
let subject =
renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
renderer::render_template(TemplateType::Subject, template_name, data)?;
let html_part =
renderer::render_template(TemplateRenderer::Html, body_template, data)?;
renderer::render_template(TemplateType::HtmlBody, template_name, data)?;
let text_part =
renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
renderer::render_template(TemplateType::PlaintextBody, template_name, data)?;
email_builder = email_builder.subject(subject);

View File

@ -162,10 +162,8 @@ pub trait Endpoint {
pub enum Content {
/// Title and body will be rendered as a template
Template {
/// Template for the notification title.
title_template: String,
/// Template for the notification body.
body_template: String,
/// Name of the used template
template_name: String,
/// Data that can be used for template rendering.
data: Value,
},
@ -203,10 +201,9 @@ pub struct Notification {
}
impl Notification {
pub fn new_templated<S: AsRef<str>>(
pub fn from_template<S: AsRef<str>>(
severity: Severity,
title: S,
body: S,
template_name: S,
template_data: Value,
fields: HashMap<String, String>,
) -> Self {
@ -217,8 +214,7 @@ impl Notification {
timestamp: proxmox_time::epoch_i64(),
},
content: Content::Template {
title_template: title.as_ref().to_string(),
body_template: body.as_ref().to_string(),
template_name: template_name.as_ref().to_string(),
data: template_data,
},
}
@ -549,8 +545,7 @@ impl Bus {
timestamp: proxmox_time::epoch_i64(),
},
content: Content::Template {
title_template: "Test notification".into(),
body_template: "This is a test of the notification target '{{ target }}'".into(),
template_name: "test".to_string(),
data: json!({ "target": target }),
},
};
@ -623,10 +618,9 @@ mod tests {
bus.add_matcher(matcher);
// Send directly to endpoint
bus.send(&Notification::new_templated(
bus.send(&Notification::from_template(
Severity::Info,
"Title",
"Body",
"test",
Default::default(),
Default::default(),
));
@ -661,10 +655,9 @@ mod tests {
});
let send_with_severity = |severity| {
let notification = Notification::new_templated(
let notification = Notification::from_template(
severity,
"Title",
"Body",
"test",
Default::default(),
Default::default(),
);

View File

@ -456,7 +456,7 @@ mod tests {
fields.insert("foo".into(), "bar".into());
let notification =
Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
let matcher: FieldMatcher = "exact:foo=bar".parse().unwrap();
assert!(matcher.matches(&notification).unwrap());
@ -474,14 +474,14 @@ mod tests {
fields.insert("foo".into(), "test".into());
let notification =
Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
assert!(matcher.matches(&notification).unwrap());
let mut fields = HashMap::new();
fields.insert("foo".into(), "notthere".into());
let notification =
Notification::new_templated(Severity::Notice, "test", "test", Value::Null, fields);
Notification::from_template(Severity::Notice, "test", Value::Null, fields);
assert!(!matcher.matches(&notification).unwrap());
assert!("regex:'3=b.*".parse::<FieldMatcher>().is_err());
@ -489,13 +489,8 @@ mod tests {
}
#[test]
fn test_severities() {
let notification = Notification::new_templated(
Severity::Notice,
"test",
"test",
Value::Null,
Default::default(),
);
let notification =
Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
let matcher: SeverityMatcher = "info,notice,warning,error".parse().unwrap();
assert!(matcher.matches(&notification).unwrap());
@ -503,13 +498,8 @@ mod tests {
#[test]
fn test_empty_matcher_matches_always() {
let notification = Notification::new_templated(
Severity::Notice,
"test",
"test",
Value::Null,
Default::default(),
);
let notification =
Notification::from_template(Severity::Notice, "test", Value::Null, Default::default());
for mode in [MatchModeOperator::All, MatchModeOperator::Any] {
let config = MatcherConfig {

View File

@ -5,7 +5,6 @@ use handlebars::{
use serde_json::Value;
use super::{table::Table, value_to_string};
use crate::define_helper_with_prefix_and_postfix;
use crate::renderer::BlockRenderFunctions;
fn render_html_table(
@ -79,22 +78,9 @@ fn render_object(
Ok(())
}
define_helper_with_prefix_and_postfix!(verbatim_monospaced, "<pre>", "</pre>");
define_helper_with_prefix_and_postfix!(heading_1, "<h1 style=\"font-size: 1.2em\">", "</h1>");
define_helper_with_prefix_and_postfix!(heading_2, "<h2 style=\"font-size: 1em\">", "</h2>");
define_helper_with_prefix_and_postfix!(
verbatim,
"<pre style=\"font-family: sans-serif\">",
"</pre>"
);
pub(super) fn block_render_functions() -> BlockRenderFunctions {
BlockRenderFunctions {
table: Box::new(render_html_table),
verbatim_monospaced: Box::new(verbatim_monospaced),
object: Box::new(render_object),
heading_1: Box::new(heading_1),
heading_2: Box::new(heading_2),
verbatim: Box::new(verbatim),
}
}

View File

@ -12,7 +12,7 @@ use serde_json::Value;
use proxmox_human_byte::HumanByte;
use proxmox_time::TimeSpan;
use crate::Error;
use crate::{context, Error};
mod html;
mod plaintext;
@ -165,41 +165,47 @@ impl ValueRenderFunction {
}
}
/// Available renderers for notification templates.
/// Available template types
#[derive(Copy, Clone)]
pub enum TemplateRenderer {
/// Render to HTML code
Html,
/// Render to plain text
Plaintext,
pub enum TemplateType {
/// HTML body template
HtmlBody,
/// Plaintext body template
PlaintextBody,
/// Plaintext body template
Subject,
}
impl TemplateRenderer {
fn prefix(&self) -> &str {
impl TemplateType {
fn file_suffix(&self) -> &'static str {
match self {
TemplateRenderer::Html => "<html>\n<body>\n",
TemplateRenderer::Plaintext => "",
TemplateType::HtmlBody => "body.html.hbs",
TemplateType::PlaintextBody => "body.txt.hbs",
TemplateType::Subject => "subject.txt.hbs",
}
}
fn postfix(&self) -> &str {
match self {
TemplateRenderer::Html => "\n</body>\n</html>",
TemplateRenderer::Plaintext => "",
fn postprocess(&self, mut rendered: String) -> String {
if let Self::Subject = self {
rendered = rendered.replace('\n', " ");
}
rendered
}
fn block_render_fns(&self) -> BlockRenderFunctions {
match self {
TemplateRenderer::Html => html::block_render_functions(),
TemplateRenderer::Plaintext => plaintext::block_render_functions(),
TemplateType::HtmlBody => html::block_render_functions(),
TemplateType::Subject => plaintext::block_render_functions(),
TemplateType::PlaintextBody => plaintext::block_render_functions(),
}
}
fn escape_fn(&self) -> fn(&str) -> String {
match self {
TemplateRenderer::Html => handlebars::html_escape,
TemplateRenderer::Plaintext => handlebars::no_escape,
TemplateType::PlaintextBody => handlebars::no_escape,
TemplateType::Subject => handlebars::no_escape,
TemplateType::HtmlBody => handlebars::html_escape,
}
}
}
@ -208,28 +214,20 @@ type HelperFn = dyn HelperDef + Send + Sync;
struct BlockRenderFunctions {
table: Box<HelperFn>,
verbatim_monospaced: Box<HelperFn>,
object: Box<HelperFn>,
heading_1: Box<HelperFn>,
heading_2: Box<HelperFn>,
verbatim: Box<HelperFn>,
}
impl BlockRenderFunctions {
fn register_helpers(self, handlebars: &mut Handlebars) {
handlebars.register_helper("table", self.table);
handlebars.register_helper("verbatim", self.verbatim);
handlebars.register_helper("verbatim-monospaced", self.verbatim_monospaced);
handlebars.register_helper("object", self.object);
handlebars.register_helper("heading-1", self.heading_1);
handlebars.register_helper("heading-2", self.heading_2);
}
}
fn render_template_impl(
template: &str,
data: &Value,
renderer: TemplateRenderer,
renderer: TemplateType,
) -> Result<String, Error> {
let mut handlebars = Handlebars::new();
handlebars.register_escape_fn(renderer.escape_fn());
@ -248,61 +246,45 @@ fn render_template_impl(
/// Render a template string.
///
/// The output format can be chosen via the `renderer` parameter (see [TemplateRenderer]
/// The output format can be chosen via the `renderer` parameter (see [TemplateType]
/// for available options).
pub fn render_template(
renderer: TemplateRenderer,
mut ty: TemplateType,
template: &str,
data: &Value,
) -> Result<String, Error> {
let mut rendered_template = String::from(renderer.prefix());
let filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
rendered_template.push_str(&render_template_impl(template, data, renderer)?);
rendered_template.push_str(renderer.postfix());
let template_string = context::context().lookup_template(&filename, None)?;
Ok(rendered_template)
}
#[macro_export]
macro_rules! define_helper_with_prefix_and_postfix {
($name:ident, $pre:expr, $post:expr) => {
fn $name<'reg, 'rc>(
h: &Helper<'reg, 'rc>,
handlebars: &'reg Handlebars,
context: &'rc Context,
render_context: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> HelperResult {
use handlebars::Renderable;
let block_text = h.template();
let param = h.param(0);
out.write($pre)?;
match (param, block_text) {
(None, Some(block_text)) => {
block_text.render(handlebars, context, render_context, out)
}
(Some(param), None) => {
let value = param.value();
let text = value.as_str().ok_or_else(|| {
HandlebarsRenderError::new(format!("value {value} is not a string"))
})?;
out.write(text)?;
Ok(())
}
(Some(_), Some(_)) => Err(HandlebarsRenderError::new(
"Cannot use parameter and template at the same time",
)),
(None, None) => Err(HandlebarsRenderError::new(
"Neither parameter nor template was provided",
)),
}?;
out.write($post)?;
Ok(())
let (template_string, fallback) = match (template_string, ty) {
(None, TemplateType::HtmlBody) => {
ty = TemplateType::PlaintextBody;
let plaintext_filename = format!("{template}-{suffix}", suffix = ty.file_suffix());
log::info!("html template '{filename}' not found, falling back to plain text template '{plaintext_filename}'");
(
context::context().lookup_template(&plaintext_filename, None)?,
true,
)
}
(template_string, _) => (template_string, false),
};
let template_string = template_string.ok_or(Error::Generic(format!(
"could not load template '{template}'"
)))?;
let mut rendered = render_template_impl(&template_string, data, ty)?;
rendered = ty.postprocess(rendered);
if fallback {
rendered = format!(
"<html><body><pre>{}</pre></body></html>",
handlebars::html_escape(&rendered)
);
}
Ok(rendered)
}
#[cfg(test)]
@ -310,73 +292,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_render_template() -> Result<(), Error> {
let data = json!({
"dur": 12345,
"size": 1024 * 15,
"table": {
"schema": {
"columns": [
{
"id": "col1",
"label": "Column 1"
},
{
"id": "col2",
"label": "Column 2"
}
]
},
"data": [
{
"col1": "val1",
"col2": "val2"
},
{
"col1": "val3",
"col2": "val4"
},
]
}
});
let template = r#"
{{heading-1 "Hello World"}}
{{heading-2 "Hello World"}}
{{human-bytes size}}
{{duration dur}}
{{table table}}"#;
let expected_plaintext = r#"
Hello World
===========
Hello World
-----------
15 KiB
3h 25min 45s
Column 1 Column 2
val1 val2
val3 val4
"#;
let rendered_plaintext = render_template(TemplateRenderer::Plaintext, template, &data)?;
// Let's not bother about testing the HTML output, too fragile.
assert_eq!(rendered_plaintext, expected_plaintext);
Ok(())
}
#[test]
fn test_helpers() {
assert_eq!(value_to_byte_size(&json!(1024)), Some("1 KiB".to_string()));

View File

@ -7,7 +7,6 @@ use handlebars::{
use serde_json::Value;
use super::{table::Table, value_to_string};
use crate::define_helper_with_prefix_and_postfix;
use crate::renderer::BlockRenderFunctions;
fn optimal_column_widths(table: &Table) -> HashMap<&str, usize> {
@ -76,40 +75,6 @@ fn render_plaintext_table(
Ok(())
}
macro_rules! define_underlining_heading_fn {
($name:ident, $underline:expr) => {
fn $name<'reg, 'rc>(
h: &Helper<'reg, 'rc>,
_handlebars: &'reg Handlebars,
_context: &'rc Context,
_render_context: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> HelperResult {
let param = h
.param(0)
.ok_or_else(|| HandlebarsRenderError::new("No parameter provided"))?;
let value = param.value();
let text = value.as_str().ok_or_else(|| {
HandlebarsRenderError::new(format!("value {value} is not a string"))
})?;
out.write(text)?;
out.write("\n")?;
for _ in 0..text.len() {
out.write($underline)?;
}
Ok(())
}
};
}
define_helper_with_prefix_and_postfix!(verbatim_monospaced, "", "");
define_underlining_heading_fn!(heading_1, "=");
define_underlining_heading_fn!(heading_2, "-");
define_helper_with_prefix_and_postfix!(verbatim, "", "");
fn render_object(
h: &Helper,
_: &Handlebars,
@ -133,10 +98,6 @@ fn render_object(
pub(super) fn block_render_functions() -> BlockRenderFunctions {
BlockRenderFunctions {
table: Box::new(render_plaintext_table),
verbatim_monospaced: Box::new(verbatim_monospaced),
verbatim: Box::new(verbatim),
object: Box::new(render_object),
heading_1: Box::new(heading_1),
heading_2: Box::new(heading_2),
}
}