Various features lumped together

Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
Manos Pitsidianakis 2024-04-26 13:39:03 +03:00
parent 393446ea61
commit 568472a2e7
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
10 changed files with 947 additions and 827 deletions

View File

@ -92,7 +92,6 @@ pub mod typed_paths;
pub mod utils;
pub use auth::*;
pub use cal::{calendarize, *};
pub use help::*;
pub use lists::{
list, list_candidates, list_edit, list_edit_POST, list_post, list_post_eml, list_post_raw,

View File

@ -218,7 +218,7 @@ pub async fn list_post(
url: ListPath(list.id.to_string().into()).to_crumb(),
},
Crumb {
label: format!("{} {msg_id}", subject_ref).into(),
label: format!("{} <{}>", subject_ref, msg_id.as_str().strip_carets()).into(),
url: ListPostPath(list.id.to_string().into(), msg_id.to_string()).to_crumb(),
},
];

View File

@ -19,14 +19,16 @@
//! Utils for templates with the [`minijinja`] crate.
use std::fmt::Write;
use mailpot::models::ListOwner;
pub use mailpot::StripCarets;
use super::*;
mod compressed;
mod filters;
mod objects;
pub use filters::*;
pub use objects::*;
lazy_static::lazy_static! {
pub static ref TEMPLATES: Environment<'static> = {
@ -41,7 +43,9 @@ lazy_static::lazy_static! {
}
add!(function calendarize,
strip_carets,
ensure_carets,
urlize,
url_encode,
heading,
topics,
login_path,
@ -55,7 +59,8 @@ lazy_static::lazy_static! {
list_candidates_path,
list_post_path,
post_raw_path,
post_eml_path
post_eml_path,
post_mbox_path
);
add!(filter pluralize);
// Load compressed templates. They are constructed in build.rs. See
@ -76,818 +81,3 @@ lazy_static::lazy_static! {
env
};
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
pub struct MailingList {
pub pk: i64,
pub name: String,
pub id: String,
pub address: String,
pub description: Option<String>,
pub topics: Vec<String>,
#[serde(serialize_with = "super::utils::to_safe_string_opt")]
pub archive_url: Option<String>,
pub inner: DbVal<mailpot::models::MailingList>,
#[serde(default)]
pub is_description_html_safe: bool,
}
impl MailingList {
/// Set whether it's safe to not escape the list's description field.
///
/// If anyone can display arbitrary html in the server, that's bad.
///
/// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
/// `ListOwner` slices.
pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
&mut self,
owners: &[O],
administrators: &[String],
) {
if owners.is_empty() || administrators.is_empty() {
return;
}
self.is_description_html_safe = owners
.iter()
.any(|o| administrators.contains(&o.borrow().address));
}
}
impl From<DbVal<mailpot::models::MailingList>> for MailingList {
fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
let DbVal(
mailpot::models::MailingList {
pk,
name,
id,
address,
description,
topics,
archive_url,
},
_,
) = val.clone();
Self {
pk,
name,
id,
address,
description,
topics,
archive_url,
inner: val,
is_description_html_safe: false,
}
}
}
impl std::fmt::Display for MailingList {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
self.id.fmt(fmt)
}
}
impl Object for MailingList {
fn kind(&self) -> minijinja::value::ObjectKind {
minijinja::value::ObjectKind::Struct(self)
}
fn call_method(
&self,
_state: &minijinja::State,
name: &str,
_args: &[Value],
) -> std::result::Result<Value, Error> {
match name {
"subscription_mailto" => {
Ok(Value::from_serializable(&self.inner.subscription_mailto()))
}
"unsubscription_mailto" => Ok(Value::from_serializable(
&self.inner.unsubscription_mailto(),
)),
"topics" => topics_common(&self.topics),
_ => Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("object has no method named {name}"),
)),
}
}
}
impl minijinja::value::StructObject for MailingList {
fn get_field(&self, name: &str) -> Option<Value> {
match name {
"pk" => Some(Value::from_serializable(&self.pk)),
"name" => Some(Value::from_serializable(&self.name)),
"id" => Some(Value::from_serializable(&self.id)),
"address" => Some(Value::from_serializable(&self.address)),
"description" if self.is_description_html_safe => {
self.description.as_ref().map_or_else(
|| Some(Value::from_serializable(&self.description)),
|d| Some(Value::from_safe_string(d.clone())),
)
}
"description" => Some(Value::from_serializable(&self.description)),
"topics" => Some(Value::from_serializable(&self.topics)),
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
"is_description_html_safe" => {
Some(Value::from_serializable(&self.is_description_html_safe))
}
_ => None,
}
}
fn static_fields(&self) -> Option<&'static [&'static str]> {
Some(
&[
"pk",
"name",
"id",
"address",
"description",
"topics",
"archive_url",
"is_description_html_safe",
][..],
)
}
}
/// Return a vector of weeks, with each week being a vector of 7 days and
/// corresponding sum of posts per day.
pub fn calendarize(
_state: &minijinja::State,
args: Value,
hists: Value,
) -> std::result::Result<Value, Error> {
use chrono::Month;
macro_rules! month {
($int:expr) => {{
let int = $int;
match int {
1 => Month::January.name(),
2 => Month::February.name(),
3 => Month::March.name(),
4 => Month::April.name(),
5 => Month::May.name(),
6 => Month::June.name(),
7 => Month::July.name(),
8 => Month::August.name(),
9 => Month::September.name(),
10 => Month::October.name(),
11 => Month::November.name(),
12 => Month::December.name(),
_ => unreachable!(),
}
}};
}
let month = args.as_str().unwrap();
let hist = hists
.get_item(&Value::from(month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.collect::<Vec<usize>>();
let sum: usize = hists
.get_item(&Value::from(month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.sum();
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
Ok(minijinja::context! {
month_name => month!(date.month()),
month => month,
month_int => date.month() as usize,
year => date.year(),
weeks => cal::calendarize_with_offset(date, 1),
hist => hist,
sum,
})
}
/// `pluralize` filter for [`minijinja`].
///
/// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
/// length `1`. By default, the plural suffix is 's' and the singular suffix is
/// empty (''). You can specify a singular suffix as the first argument (or
/// `None`, for the default). You can specify a plural suffix as the second
/// argument (or `None`, for the default).
///
/// See the examples for the correct usage.
///
/// # Examples
///
/// ```rust,no_run
/// # use mailpot_web::pluralize;
/// # use minijinja::Environment;
///
/// let mut env = Environment::new();
/// env.add_filter("pluralize", pluralize);
/// for (num, s) in [
/// (0, "You have 0 messages."),
/// (1, "You have 1 message."),
/// (10, "You have 10 messages."),
/// ] {
/// assert_eq!(
/// &env.render_str(
/// "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
/// minijinja::context! {
/// num_messages => num,
/// }
/// )
/// .unwrap(),
/// s
/// );
/// }
///
/// for (num, s) in [
/// (0, "You have 0 walruses."),
/// (1, "You have 1 walrus."),
/// (10, "You have 10 walruses."),
/// ] {
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
/// minijinja::context! {
/// num_walruses => num,
/// }
/// )
/// .unwrap(),
/// s
/// );
/// }
///
/// for (num, s) in [
/// (0, "You have 0 cherries."),
/// (1, "You have 1 cherry."),
/// (10, "You have 10 cherries."),
/// ] {
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => num,
/// }
/// )
/// .unwrap(),
/// s
/// );
/// }
///
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => vec![(); 5],
/// }
/// )
/// .unwrap(),
/// "You have 5 cherries."
/// );
///
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => "5",
/// }
/// )
/// .unwrap(),
/// "You have 5 cherries."
/// );
/// assert_eq!(
/// &env.render_str(
/// r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => true,
/// }
/// )
/// .unwrap()
/// .to_string(),
/// "You have 1 cherry.",
/// );
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => 0.5f32,
/// }
/// )
/// .unwrap_err()
/// .to_string(),
/// "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
/// length but of type number (in <string>:1)",
/// );
/// ```
pub fn pluralize(
v: Value,
singular: Option<String>,
plural: Option<String>,
) -> Result<Value, minijinja::Error> {
macro_rules! int_try_from {
($ty:ty) => {
<$ty>::try_from(v.clone()).ok().map(|v| v != 1)
};
($fty:ty, $($ty:ty),*) => {
int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
}
}
let is_plural: bool = v
.as_str()
.and_then(|s| s.parse::<i128>().ok())
.map(|l| l != 1)
.or_else(|| v.len().map(|l| l != 1))
.or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
.ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"Pluralize argument is not an integer, or a sequence / object with a length \
but of type {}",
v.kind()
),
)
})?;
Ok(match (is_plural, singular, plural) {
(false, None, _) => "".into(),
(false, Some(suffix), _) => suffix.into(),
(true, _, None) => "s".into(),
(true, _, Some(suffix)) => suffix.into(),
})
}
/// `strip_carets` filter for [`minijinja`].
///
/// Removes `[<>]` from message ids.
pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok(Value::from(
arg.as_str()
.ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("argument to strip_carets() is of type {}", arg.kind()),
)
})?
.strip_carets(),
))
}
/// `urlize` filter for [`minijinja`].
///
/// Returns a safe string for use in `<a href=..` attributes.
///
/// # Examples
///
/// ```rust,no_run
/// # use mailpot_web::urlize;
/// # use minijinja::Environment;
/// # use minijinja::value::Value;
///
/// let mut env = Environment::new();
/// env.add_function("urlize", urlize);
/// env.add_global(
/// "root_url_prefix",
/// Value::from_safe_string("/lists/prefix/".to_string()),
/// );
/// assert_eq!(
/// &env.render_str(
/// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
/// minijinja::context! {}
/// )
/// .unwrap(),
/// "<a href=\"/lists/prefix/path/index.html\">link</a>",
/// );
/// ```
pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
let Some(prefix) = state.lookup("root_url_prefix") else {
return Ok(arg);
};
Ok(Value::from_safe_string(format!("{prefix}{arg}")))
}
/// Make an html heading: `h1, h2, h3` etc.
///
/// # Example
/// ```rust,no_run
/// use mailpot_web::minijinja_utils::heading;
/// use minijinja::value::Value;
///
/// assert_eq!(
/// "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
/// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
/// );
/// assert_eq!(
/// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
/// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
/// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
/// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
/// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// ```
pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
use convert_case::{Case, Casing};
macro_rules! test {
() => {
|n| *n > 0 && *n < 7
};
}
macro_rules! int_try_from {
($ty:ty) => {
<$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
};
($fty:ty, $($ty:ty),*) => {
int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
}
}
let level: u8 = level
.as_str()
.and_then(|s| s.parse::<i128>().ok())
.filter(test! {})
.map(|n| n as u8)
.or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
.ok_or_else(|| {
if matches!(level.kind(), minijinja::value::ValueKind::Number) {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"first heading() argument must be an unsigned integer less than 7 and positive",
)
} else {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"first heading() argument is not an integer < 7 but of type {}",
level.kind()
),
)
}
})?;
let text = text.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"second heading() argument is not a string but of type {}",
text.kind()
),
)
})?;
if let Some(v) = id {
let kebab = v.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"third heading() argument is not a string but of type {}",
v.kind()
),
)
})?;
Ok(Value::from_safe_string(format!(
"<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
href=\"#{kebab}\"></a></h{level}>"
)))
} else {
let kebab_v = text.to_case(Case::Kebab);
let kebab =
percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
Ok(Value::from_safe_string(format!(
"<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
href=\"#{kebab}\"></a></h{level}>"
)))
}
}
/// Make an array of topic strings into html badges.
///
/// # Example
/// ```rust
/// use mailpot_web::minijinja_utils::topics;
/// use minijinja::value::Value;
///
/// let v: Value = topics(Value::from_serializable(&vec![
/// "a".to_string(),
/// "aab".to_string(),
/// "aaab".to_string(),
/// ]))
/// .unwrap();
/// assert_eq!(
/// "<ul class=\"tags\"><li class=\"tag\" style=\"--red:110;--green:120;--blue:180;\"><span \
/// class=\"tag-name\"><a href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
/// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
/// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
/// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
/// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
/// &v.to_string()
/// );
/// ```
pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
topics.try_iter()?;
let topics: Vec<String> = topics
.try_iter()?
.map(|v| v.to_string())
.collect::<Vec<String>>();
topics_common(&topics)
}
pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
let mut ul = String::new();
write!(&mut ul, r#"<ul class="tags">"#)?;
for topic in topics {
write!(
&mut ul,
r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
)?;
write!(&mut ul, "{}", TopicsPath)?;
write!(&mut ul, r#"?query="#)?;
write!(
&mut ul,
"{}",
utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
)?;
write!(&mut ul, r#"">"#)?;
write!(&mut ul, "{}", topic)?;
write!(&mut ul, r#"</a></span></li>"#)?;
}
write!(&mut ul, r#"</ul>"#)?;
Ok(Value::from_safe_string(ul))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pluralize() {
let mut env = Environment::new();
env.add_filter("pluralize", pluralize);
for (num, s) in [
(0, "You have 0 messages."),
(1, "You have 1 message."),
(10, "You have 10 messages."),
] {
assert_eq!(
&env.render_str(
"You have {{ num_messages }} message{{ num_messages|pluralize }}.",
minijinja::context! {
num_messages => num,
}
)
.unwrap(),
s
);
}
for (num, s) in [
(0, "You have 0 walruses."),
(1, "You have 1 walrus."),
(10, "You have 10 walruses."),
] {
assert_eq!(
&env.render_str(
r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
minijinja::context! {
num_walruses => num,
}
)
.unwrap(),
s
);
}
for (num, s) in [
(0, "You have 0 cherries."),
(1, "You have 1 cherry."),
(10, "You have 10 cherries."),
] {
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => num,
}
)
.unwrap(),
s
);
}
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => vec![(); 5],
}
)
.unwrap(),
"You have 5 cherries."
);
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => "5",
}
)
.unwrap(),
"You have 5 cherries."
);
assert_eq!(
&env.render_str(
r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => true,
}
)
.unwrap(),
"You have 1 cherry.",
);
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => 0.5f32,
}
)
.unwrap_err()
.to_string(),
"invalid operation: Pluralize argument is not an integer, or a sequence / object with \
a length but of type number (in <string>:1)",
);
}
#[test]
fn test_urlize() {
let mut env = Environment::new();
env.add_function("urlize", urlize);
env.add_global(
"root_url_prefix",
Value::from_safe_string("/lists/prefix/".to_string()),
);
assert_eq!(
&env.render_str(
"<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
minijinja::context! {}
)
.unwrap(),
"<a href=\"/lists/prefix/path/index.html\">link</a>",
);
}
#[test]
fn test_heading() {
assert_eq!(
"<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a \
class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
&heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None)
.unwrap()
.to_string()
);
assert_eq!(
"<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" \
href=\"#short\"></a></h2>",
&heading(
2.into(),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap()
.to_string()
);
assert_eq!(
r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
&heading(
0.into(),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap_err()
.to_string()
);
assert_eq!(
r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
&heading(
8.into(),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap_err()
.to_string()
);
assert_eq!(
r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
&heading(
Value::from(vec![Value::from(1)]),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap_err()
.to_string()
);
}
#[test]
fn test_strip_carets() {
let mut env = Environment::new();
env.add_filter("strip_carets", strip_carets);
assert_eq!(
&env.render_str(
"{{ msg_id | strip_carets }}",
minijinja::context! {
msg_id => "<hello1@example.com>",
}
)
.unwrap(),
"hello1@example.com",
);
}
#[test]
fn test_calendarize() {
use std::collections::HashMap;
let mut env = Environment::new();
env.add_function("calendarize", calendarize);
let month = "2001-09";
let mut hist = [0usize; 31];
hist[15] = 5;
hist[1] = 1;
hist[0] = 512;
hist[30] = 30;
assert_eq!(
&env.render_str(
"{% set c=calendarize(month, hists) %}Month: {{ c.month }} Month Name: {{ \
c.month_name }} Month Int: {{ c.month_int }} Year: {{ c.year }} Sum: {{ c.sum }} {% \
for week in c.weeks %}{% for day in week %}{% set num = c.hist[day-1] %}({{ day }}, \
{{ num }}){% endfor %}{% endfor %}",
minijinja::context! {
month,
hists => vec![(month.to_string(), hist)].into_iter().collect::<HashMap<String, [usize;
31]>>(),
}
)
.unwrap(),
"Month: 2001-09 Month Name: September Month Int: 9 Year: 2001 Sum: 548 (0, 30)(0, 30)(0, \
30)(0, 30)(0, 30)(1, 512)(2, 1)(3, 0)(4, 0)(5, 0)(6, 0)(7, 0)(8, 0)(9, 0)(10, 0)(11, \
0)(12, 0)(13, 0)(14, 0)(15, 0)(16, 5)(17, 0)(18, 0)(19, 0)(20, 0)(21, 0)(22, 0)(23, \
0)(24, 0)(25, 0)(26, 0)(27, 0)(28, 0)(29, 0)(30, 0)"
);
}
#[test]
fn test_list_html_safe() {
let mut list = MailingList {
pk: 0,
name: String::new(),
id: String::new(),
address: String::new(),
description: None,
topics: vec![],
archive_url: None,
inner: DbVal(
mailpot::models::MailingList {
pk: 0,
name: String::new(),
id: String::new(),
address: String::new(),
description: None,
topics: vec![],
archive_url: None,
},
0,
),
is_description_html_safe: false,
};
let mut list_owners = vec![ListOwner {
pk: 0,
list: 0,
address: "admin@example.com".to_string(),
name: None,
}];
let administrators = vec!["admin@example.com".to_string()];
list.set_safety(&list_owners, &administrators);
assert!(list.is_description_html_safe);
list.set_safety::<ListOwner>(&[], &[]);
assert!(list.is_description_html_safe);
list.is_description_html_safe = false;
list_owners[0].address = "user@example.com".to_string();
list.set_safety(&list_owners, &administrators);
assert!(!list.is_description_html_safe);
}
}

View File

@ -17,4 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//[tag:embed_templates]
/// This is an array of all templates compressed for smaller binary size.
///
/// Compression happens at compile-time in the `build.rs` script.
pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data");

View File

@ -0,0 +1,743 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Utils for templates with the [`minijinja`] crate.
use std::fmt::Write;
pub use mailpot::StripCarets;
use super::*;
/// Return a vector of weeks, with each week being a vector of 7 days and
/// corresponding sum of posts per day.
pub fn calendarize(
_state: &minijinja::State,
args: Value,
hists: Value,
) -> std::result::Result<Value, Error> {
use chrono::Month;
macro_rules! month {
($int:expr) => {{
let int = $int;
match int {
1 => Month::January.name(),
2 => Month::February.name(),
3 => Month::March.name(),
4 => Month::April.name(),
5 => Month::May.name(),
6 => Month::June.name(),
7 => Month::July.name(),
8 => Month::August.name(),
9 => Month::September.name(),
10 => Month::October.name(),
11 => Month::November.name(),
12 => Month::December.name(),
_ => unreachable!(),
}
}};
}
let month = args.as_str().unwrap();
let hist = hists
.get_item(&Value::from(month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.collect::<Vec<usize>>();
let sum: usize = hists
.get_item(&Value::from(month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.sum();
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
Ok(minijinja::context! {
month_name => month!(date.month()),
month => month,
month_int => date.month() as usize,
year => date.year(),
weeks => cal::calendarize_with_offset(date, 1),
hist => hist,
sum,
})
}
/// `pluralize` filter for [`minijinja`].
///
/// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
/// length `1`. By default, the plural suffix is 's' and the singular suffix is
/// empty (''). You can specify a singular suffix as the first argument (or
/// `None`, for the default). You can specify a plural suffix as the second
/// argument (or `None`, for the default).
///
/// See the examples for the correct usage.
///
/// # Examples
///
/// ```rust,no_run
/// # use mailpot_web::pluralize;
/// # use minijinja::Environment;
///
/// let mut env = Environment::new();
/// env.add_filter("pluralize", pluralize);
/// for (num, s) in [
/// (0, "You have 0 messages."),
/// (1, "You have 1 message."),
/// (10, "You have 10 messages."),
/// ] {
/// assert_eq!(
/// &env.render_str(
/// "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
/// minijinja::context! {
/// num_messages => num,
/// }
/// )
/// .unwrap(),
/// s
/// );
/// }
///
/// for (num, s) in [
/// (0, "You have 0 walruses."),
/// (1, "You have 1 walrus."),
/// (10, "You have 10 walruses."),
/// ] {
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
/// minijinja::context! {
/// num_walruses => num,
/// }
/// )
/// .unwrap(),
/// s
/// );
/// }
///
/// for (num, s) in [
/// (0, "You have 0 cherries."),
/// (1, "You have 1 cherry."),
/// (10, "You have 10 cherries."),
/// ] {
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => num,
/// }
/// )
/// .unwrap(),
/// s
/// );
/// }
///
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => vec![(); 5],
/// }
/// )
/// .unwrap(),
/// "You have 5 cherries."
/// );
///
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => "5",
/// }
/// )
/// .unwrap(),
/// "You have 5 cherries."
/// );
/// assert_eq!(
/// &env.render_str(
/// r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => true,
/// }
/// )
/// .unwrap()
/// .to_string(),
/// "You have 1 cherry.",
/// );
/// assert_eq!(
/// &env.render_str(
/// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
/// minijinja::context! {
/// num_cherries => 0.5f32,
/// }
/// )
/// .unwrap_err()
/// .to_string(),
/// "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
/// length but of type number (in <string>:1)",
/// );
/// ```
pub fn pluralize(
v: Value,
singular: Option<String>,
plural: Option<String>,
) -> Result<Value, minijinja::Error> {
macro_rules! int_try_from {
($ty:ty) => {
<$ty>::try_from(v.clone()).ok().map(|v| v != 1)
};
($fty:ty, $($ty:ty),*) => {
int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
}
}
let is_plural: bool = v
.as_str()
.and_then(|s| s.parse::<i128>().ok())
.map(|l| l != 1)
.or_else(|| v.len().map(|l| l != 1))
.or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
.ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"Pluralize argument is not an integer, or a sequence / object with a length \
but of type {}",
v.kind()
),
)
})?;
Ok(match (is_plural, singular, plural) {
(false, None, _) => "".into(),
(false, Some(suffix), _) => suffix.into(),
(true, _, None) => "s".into(),
(true, _, Some(suffix)) => suffix.into(),
})
}
/// `strip_carets` filter for [`minijinja`].
///
/// Removes `[<>]` from message ids.
pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok(Value::from(
arg.as_str()
.ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("argument to strip_carets() is of type {}", arg.kind()),
)
})?
.strip_carets(),
))
}
/// `ensure_carets` filter for [`minijinja`].
///
/// Makes sure message id value is surrounded by carets `[<>].
pub fn ensure_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok({
let s = arg.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("argument to ensure_carets() is of type {}", arg.kind()),
)
})?;
if !s.trim().starts_with('<') && !s.ends_with('>') {
Value::from(format!("<{s}>"))
} else {
Value::from(s)
}
})
}
/// `urlize` filter for [`minijinja`].
///
/// Returns a safe string for use in `<a href=..` attributes.
///
/// # Examples
///
/// ```rust,no_run
/// # use mailpot_web::urlize;
/// # use minijinja::Environment;
/// # use minijinja::value::Value;
///
/// let mut env = Environment::new();
/// env.add_function("urlize", urlize);
/// env.add_global(
/// "root_url_prefix",
/// Value::from_safe_string("/lists/prefix/".to_string()),
/// );
/// assert_eq!(
/// &env.render_str(
/// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
/// minijinja::context! {}
/// )
/// .unwrap(),
/// "<a href=\"/lists/prefix/path/index.html\">link</a>",
/// );
/// ```
pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
let Some(prefix) = state.lookup("root_url_prefix") else {
return Ok(arg);
};
Ok(Value::from_safe_string(format!("{prefix}{arg}")))
}
pub fn url_encode(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok(Value::from_safe_string(
utf8_percent_encode(
arg.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"url_decode() argument is not a string but of type {}",
arg.kind()
),
)
})?,
crate::typed_paths::PATH_SEGMENT,
)
.to_string(),
))
}
/// Make an html heading: `h1, h2, h3` etc.
///
/// # Example
/// ```rust,no_run
/// use mailpot_web::minijinja_utils::heading;
/// use minijinja::value::Value;
///
/// assert_eq!(
/// "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
/// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
/// );
/// assert_eq!(
/// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
/// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
/// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
/// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
/// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// ```
pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
use convert_case::{Case, Casing};
macro_rules! test {
() => {
|n| *n > 0 && *n < 7
};
}
macro_rules! int_try_from {
($ty:ty) => {
<$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
};
($fty:ty, $($ty:ty),*) => {
int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
}
}
let level: u8 = level
.as_str()
.and_then(|s| s.parse::<i128>().ok())
.filter(test! {})
.map(|n| n as u8)
.or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
.ok_or_else(|| {
if matches!(level.kind(), minijinja::value::ValueKind::Number) {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"first heading() argument must be an unsigned integer less than 7 and positive",
)
} else {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"first heading() argument is not an integer < 7 but of type {}",
level.kind()
),
)
}
})?;
let text = text.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"second heading() argument is not a string but of type {}",
text.kind()
),
)
})?;
if let Some(v) = id {
let kebab = v.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"third heading() argument is not a string but of type {}",
v.kind()
),
)
})?;
Ok(Value::from_safe_string(format!(
"<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
href=\"#{kebab}\"></a></h{level}>"
)))
} else {
let kebab_v = text.to_case(Case::Kebab);
let kebab =
percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
Ok(Value::from_safe_string(format!(
"<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
href=\"#{kebab}\"></a></h{level}>"
)))
}
}
/// Make an array of topic strings into html badges.
///
/// # Example
/// ```rust
/// use mailpot_web::minijinja_utils::topics;
/// use minijinja::value::Value;
///
/// let v: Value = topics(Value::from_serializable(&vec![
/// "a".to_string(),
/// "aab".to_string(),
/// "aaab".to_string(),
/// ]))
/// .unwrap();
/// assert_eq!(
/// "<ul class=\"tags\"><li class=\"tag\" style=\"--red:110;--green:120;--blue:180;\"><span \
/// class=\"tag-name\"><a href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
/// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
/// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
/// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
/// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
/// &v.to_string()
/// );
/// ```
pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
topics.try_iter()?;
let topics: Vec<String> = topics
.try_iter()?
.map(|v| v.to_string())
.collect::<Vec<String>>();
topics_common(&topics)
}
pub fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
let mut ul = String::new();
write!(&mut ul, r#"<ul class="tags">"#)?;
for topic in topics {
write!(
&mut ul,
r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
)?;
write!(&mut ul, "{}", TopicsPath)?;
write!(&mut ul, r#"?query="#)?;
write!(
&mut ul,
"{}",
utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
)?;
write!(&mut ul, r#"">"#)?;
write!(&mut ul, "{}", topic)?;
write!(&mut ul, r#"</a></span></li>"#)?;
}
write!(&mut ul, r#"</ul>"#)?;
Ok(Value::from_safe_string(ul))
}
#[cfg(test)]
mod tests {
use mailpot::models::ListOwner;
use super::*;
#[test]
fn test_pluralize() {
let mut env = Environment::new();
env.add_filter("pluralize", pluralize);
for (num, s) in [
(0, "You have 0 messages."),
(1, "You have 1 message."),
(10, "You have 10 messages."),
] {
assert_eq!(
&env.render_str(
"You have {{ num_messages }} message{{ num_messages|pluralize }}.",
minijinja::context! {
num_messages => num,
}
)
.unwrap(),
s
);
}
for (num, s) in [
(0, "You have 0 walruses."),
(1, "You have 1 walrus."),
(10, "You have 10 walruses."),
] {
assert_eq!(
&env.render_str(
r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
minijinja::context! {
num_walruses => num,
}
)
.unwrap(),
s
);
}
for (num, s) in [
(0, "You have 0 cherries."),
(1, "You have 1 cherry."),
(10, "You have 10 cherries."),
] {
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => num,
}
)
.unwrap(),
s
);
}
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => vec![(); 5],
}
)
.unwrap(),
"You have 5 cherries."
);
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => "5",
}
)
.unwrap(),
"You have 5 cherries."
);
assert_eq!(
&env.render_str(
r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => true,
}
)
.unwrap(),
"You have 1 cherry.",
);
assert_eq!(
&env.render_str(
r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
minijinja::context! {
num_cherries => 0.5f32,
}
)
.unwrap_err()
.to_string(),
"invalid operation: Pluralize argument is not an integer, or a sequence / object with \
a length but of type number (in <string>:1)",
);
}
#[test]
fn test_urlize() {
let mut env = Environment::new();
env.add_function("urlize", urlize);
env.add_global(
"root_url_prefix",
Value::from_safe_string("/lists/prefix/".to_string()),
);
assert_eq!(
&env.render_str(
"<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
minijinja::context! {}
)
.unwrap(),
"<a href=\"/lists/prefix/path/index.html\">link</a>",
);
}
#[test]
fn test_heading() {
assert_eq!(
"<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a \
class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
&heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None)
.unwrap()
.to_string()
);
assert_eq!(
"<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" \
href=\"#short\"></a></h2>",
&heading(
2.into(),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap()
.to_string()
);
assert_eq!(
r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
&heading(
0.into(),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap_err()
.to_string()
);
assert_eq!(
r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
&heading(
8.into(),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap_err()
.to_string()
);
assert_eq!(
r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
&heading(
Value::from(vec![Value::from(1)]),
"bl bfa B AH bAsdb hadas d".into(),
Some("short".into())
)
.unwrap_err()
.to_string()
);
}
#[test]
fn test_strip_carets() {
let mut env = Environment::new();
env.add_filter("strip_carets", strip_carets);
assert_eq!(
&env.render_str(
"{{ msg_id | strip_carets }}",
minijinja::context! {
msg_id => "<hello1@example.com>",
}
)
.unwrap(),
"hello1@example.com",
);
}
#[test]
fn test_calendarize() {
use std::collections::HashMap;
let mut env = Environment::new();
env.add_function("calendarize", calendarize);
let month = "2001-09";
let mut hist = [0usize; 31];
hist[15] = 5;
hist[1] = 1;
hist[0] = 512;
hist[30] = 30;
assert_eq!(
&env.render_str(
"{% set c=calendarize(month, hists) %}Month: {{ c.month }} Month Name: {{ \
c.month_name }} Month Int: {{ c.month_int }} Year: {{ c.year }} Sum: {{ c.sum }} {% \
for week in c.weeks %}{% for day in week %}{% set num = c.hist[day-1] %}({{ day }}, \
{{ num }}){% endfor %}{% endfor %}",
minijinja::context! {
month,
hists => vec![(month.to_string(), hist)].into_iter().collect::<HashMap<String, [usize;
31]>>(),
}
)
.unwrap(),
"Month: 2001-09 Month Name: September Month Int: 9 Year: 2001 Sum: 548 (0, 30)(0, 30)(0, \
30)(0, 30)(0, 30)(1, 512)(2, 1)(3, 0)(4, 0)(5, 0)(6, 0)(7, 0)(8, 0)(9, 0)(10, 0)(11, \
0)(12, 0)(13, 0)(14, 0)(15, 0)(16, 5)(17, 0)(18, 0)(19, 0)(20, 0)(21, 0)(22, 0)(23, \
0)(24, 0)(25, 0)(26, 0)(27, 0)(28, 0)(29, 0)(30, 0)"
);
}
#[test]
fn test_list_html_safe() {
let mut list = MailingList {
pk: 0,
name: String::new(),
id: String::new(),
address: String::new(),
description: None,
topics: vec![],
archive_url: None,
inner: DbVal(
mailpot::models::MailingList {
pk: 0,
name: String::new(),
id: String::new(),
address: String::new(),
description: None,
topics: vec![],
archive_url: None,
},
0,
),
is_description_html_safe: false,
};
let mut list_owners = vec![ListOwner {
pk: 0,
list: 0,
address: "admin@example.com".to_string(),
name: None,
}];
let administrators = vec!["admin@example.com".to_string()];
list.set_safety(&list_owners, &administrators);
assert!(list.is_description_html_safe);
list.set_safety::<ListOwner>(&[], &[]);
assert!(list.is_description_html_safe);
list.is_description_html_safe = false;
list_owners[0].address = "user@example.com".to_string();
list.set_safety(&list_owners, &administrators);
assert!(!list.is_description_html_safe);
}
}

View File

@ -0,0 +1,161 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Utils for templates with the [`minijinja`] crate.
use mailpot::models::ListOwner;
use super::*;
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
pub struct MailingList {
pub pk: i64,
pub name: String,
pub id: String,
pub address: String,
pub description: Option<String>,
pub topics: Vec<String>,
#[serde(serialize_with = "super::utils::to_safe_string_opt")]
pub archive_url: Option<String>,
pub inner: DbVal<mailpot::models::MailingList>,
#[serde(default)]
pub is_description_html_safe: bool,
}
impl MailingList {
/// Set whether it's safe to not escape the list's description field.
///
/// If anyone can display arbitrary html in the server, that's bad.
///
/// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
/// `ListOwner` slices.
pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
&mut self,
owners: &[O],
administrators: &[String],
) {
if owners.is_empty() || administrators.is_empty() {
return;
}
self.is_description_html_safe = owners
.iter()
.any(|o| administrators.contains(&o.borrow().address));
}
}
impl From<DbVal<mailpot::models::MailingList>> for MailingList {
fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
let DbVal(
mailpot::models::MailingList {
pk,
name,
id,
address,
description,
topics,
archive_url,
},
_,
) = val.clone();
Self {
pk,
name,
id,
address,
description,
topics,
archive_url,
inner: val,
is_description_html_safe: false,
}
}
}
impl std::fmt::Display for MailingList {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
self.id.fmt(fmt)
}
}
impl Object for MailingList {
fn kind(&self) -> minijinja::value::ObjectKind {
minijinja::value::ObjectKind::Struct(self)
}
fn call_method(
&self,
_state: &minijinja::State,
name: &str,
_args: &[Value],
) -> std::result::Result<Value, Error> {
match name {
"subscription_mailto" => {
Ok(Value::from_serializable(&self.inner.subscription_mailto()))
}
"unsubscription_mailto" => Ok(Value::from_serializable(
&self.inner.unsubscription_mailto(),
)),
"topics" => topics_common(&self.topics),
_ => Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("object has no method named {name}"),
)),
}
}
}
impl minijinja::value::StructObject for MailingList {
fn get_field(&self, name: &str) -> Option<Value> {
match name {
"pk" => Some(Value::from_serializable(&self.pk)),
"name" => Some(Value::from_serializable(&self.name)),
"id" => Some(Value::from_serializable(&self.id)),
"address" => Some(Value::from_serializable(&self.address)),
"description" if self.is_description_html_safe => {
self.description.as_ref().map_or_else(
|| Some(Value::from_serializable(&self.description)),
|d| Some(Value::from_safe_string(d.clone())),
)
}
"description" => Some(Value::from_serializable(&self.description)),
"topics" => Some(Value::from_serializable(&self.topics)),
"archive_url" => Some(Value::from_serializable(&self.archive_url)),
"is_description_html_safe" => {
Some(Value::from_serializable(&self.is_description_html_safe))
}
_ => None,
}
}
fn static_fields(&self) -> Option<&'static [&'static str]> {
Some(
&[
"pk",
"name",
"id",
"address",
"description",
"topics",
"archive_url",
"is_description_html_safe",
][..],
)
}
}

View File

@ -671,7 +671,7 @@
}
table.headers tr>th {
text-align: left;
text-align: right;
color: var(--text-faded);
}
@ -711,6 +711,7 @@
td.message-id,
span.message-id{
user-select: all;
color: var(--text-faded);
}
.message-id>a {

View File

@ -9,13 +9,25 @@
<th scope="row">From:</th>
<td><bdi>{{ post.address }}</bdi></td>
</tr>
<tr>
<th scope="row">To:</th>
<td><bdi>{% if post.to %}{{ post.to }}{% else %}{{ list.address }}{% endif %}</bdi></td>
</tr>
{% if post.cc %}
<tr>
<th scope="row">Cc:</th>
<td><bdi>{{ post.cc }}</bdi></td>
</tr>
{% endif %}
<tr>
<th scope="row">Date:</th>
<td class="faded">{{ post.datetime }}</td>
</tr>
<tr>
<th scope="row">Message-ID:</th>
<td class="faded message-id"><a href="{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
<td class="faded"><span class="message-id">{{ strip_carets(post.message_id) }}</span>
<a href="{{ list_post_path(list.id, post.message_id) }}">permalink</a> / <a href="{{ post_raw_path(list.id, post.message_id) }}" title="View raw content" type="text/plain">raw</a> / <a href="{{ post_eml_path(list.id, post.message_id) }}" title="Download as RFC 5322 format" type="message/rfc822" download>eml</a> / <a href="{{ post_mbox_path(list.id, post.message_id) }}" title="Download as an MBOX" type="application/mbox" download>mbox</a>
</td>
</tr>
{% if in_reply_to %}
<tr>
@ -29,11 +41,11 @@
<td>{% for r in references %}<span class="faded message-id"><a href="{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
</tr>
{% endif %}
<tr>
<td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td>
</tr>
</table>
<div class="post-body">
<pre {% if odd %}style="--background-secondary: var(--background-critical);" {% endif %}title="E-mail text content">{{ body|trim }}</pre>
</div>
<div class="post-reply-link">{# [ref:TODO] also reply to list email. #}
<a href="mailto:{{ url_encode(post.address) }}?In-Reply-To={{ url_encode(ensure_carets(post.message_id)) }}&amp;{% if post.cc %}Cc={{ url_encode(post.cc) }}&amp;{% endif %}Subject=Re%3A{{ url_encode(subject) }}">Reply</a>
</div>
</div>

View File

@ -94,6 +94,10 @@ pub struct ListPostRawPath(pub ListPathIdentifier, pub String);
#[typed_path("/list/:id/posts/:msgid/eml/")]
pub struct ListPostEmlPath(pub ListPathIdentifier, pub String);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/posts/:msgid/mbox/")]
pub struct ListPostMboxPath(pub ListPathIdentifier, pub String);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/edit/")]
pub struct ListEditPath(pub ListPathIdentifier);
@ -209,6 +213,7 @@ macro_rules! list_post_impl {
list_post_impl!(list_post_path, ListPostPath);
list_post_impl!(post_raw_path, ListPostRawPath);
list_post_impl!(post_eml_path, ListPostEmlPath);
list_post_impl!(post_mbox_path, ListPostMboxPath);
pub mod tsr {
use std::{borrow::Cow, convert::Infallible};

View File

@ -412,6 +412,7 @@ fn test_postfix_generation() -> Result<()> {
let mut conf = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&config_path)?;
conf.write_all(config.to_toml().as_bytes())?;
conf.flush()?;
@ -575,7 +576,11 @@ mailman unix - n n - - pipe
let path = tmp_dir.path().join("master.cf");
{
let mut mastercf = OpenOptions::new().write(true).create(true).open(&path)?;
let mut mastercf = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
mastercf.write_all(master_edit_value.as_bytes())?;
mastercf.flush()?;
}