Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: 4047111dba0d8f2c318d45746b760b4f8a9206e4
Timestamp: Fri, 26 Apr 2024 10:39:03 +0000 (4 months ago)

+976 -847 +/-15 browse
Various features lumped together
Various features lumped together

Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
1diff --git a/.cargo/config b/.cargo/config
2deleted file mode 100644
3index b2c9bde..0000000
4--- a/.cargo/config
5+++ /dev/null
6 @@ -1,2 +0,0 @@
7- [doc.extern-map.registries]
8- crates-io = "https://docs.rs/"
9 diff --git a/.cargo/config b/.cargo/config
10new file mode 120000
11index 0000000..ab8b69c
12--- /dev/null
13+++ b/.cargo/config
14 @@ -0,0 +1 @@
15+ config.toml
16\ No newline at end of file
17 diff --git a/.cargo/config.toml b/.cargo/config.toml
18new file mode 100644
19index 0000000..b2c9bde
20--- /dev/null
21+++ b/.cargo/config.toml
22 @@ -0,0 +1,2 @@
23+ [doc.extern-map.registries]
24+ crates-io = "https://docs.rs/"
25 diff --git a/mailpot-web/src/cal.rs b/mailpot-web/src/cal.rs
26index 370ebc1..ef68b50 100644
27--- a/mailpot-web/src/cal.rs
28+++ b/mailpot-web/src/cal.rs
29 @@ -30,19 +30,23 @@ use chrono::*;
30 /// A value of zero means a date that not exists in the current month.
31 ///
32 /// # Examples
33- /// ```
34+ /// ```no_run
35 /// use chrono::*;
36- /// use mailpot_web::calendarize;
37+ /// use mailpot_web::cal::calendarize;
38 ///
39 /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
40 /// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
41- /// println!("{:?}", calendarize(date));
42- /// // [0, 0, 0, 0, 0, 1, 2],
43- /// // [3, 4, 5, 6, 7, 8, 9],
44- /// // [10, 11, 12, 13, 14, 15, 16],
45- /// // [17, 18, 19, 20, 21, 22, 23],
46- /// // [24, 25, 26, 27, 28, 29, 30],
47- /// // [31, 0, 0, 0, 0, 0, 0]
48+ /// assert_eq!(
49+ /// calendarize(date),
50+ /// vec! {
51+ /// [0, 0, 0, 0, 0, 1, 2],
52+ /// [3, 4, 5, 6, 7, 8, 9],
53+ /// [10, 11, 12, 13, 14, 15, 16],
54+ /// [17, 18, 19, 20, 21, 22, 23],
55+ /// [24, 25, 26, 27, 28, 29, 30],
56+ /// [31, 0, 0, 0, 0, 0, 0]
57+ /// }
58+ /// );
59 /// ```
60 pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
61 calendarize_with_offset(date, 0)
62 @@ -58,18 +62,22 @@ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
63 /// For example, 1 means monday, 6 means saturday.
64 ///
65 /// # Examples
66- /// ```
67+ /// ```no_run
68 /// use chrono::*;
69- /// use mailpot_web::calendarize_with_offset;
70+ /// use mailpot_web::cal::calendarize_with_offset;
71 ///
72 /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
73 /// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
74- /// println!("{:?}", calendarize_with_offset(date, 1));
75- /// // [0, 0, 0, 0, 1, 2, 3],
76- /// // [4, 5, 6, 7, 8, 9, 10],
77- /// // [11, 12, 13, 14, 15, 16, 17],
78- /// // [18, 19, 20, 21, 22, 23, 24],
79- /// // [25, 26, 27, 28, 29, 30, 0],
80+ /// assert_eq!(
81+ /// calendarize_with_offset(date, 1),
82+ /// vec! {
83+ /// [0, 0, 0, 0, 1, 2, 3],
84+ /// [4, 5, 6, 7, 8, 9, 10],
85+ /// [11, 12, 13, 14, 15, 16, 17],
86+ /// [18, 19, 20, 21, 22, 23, 24],
87+ /// [25, 26, 27, 28, 29, 30, 0]
88+ /// }
89+ /// );
90 /// ```
91 pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
92 let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
93 diff --git a/mailpot-web/src/lib.rs b/mailpot-web/src/lib.rs
94index a7c35bd..351fe0d 100644
95--- a/mailpot-web/src/lib.rs
96+++ b/mailpot-web/src/lib.rs
97 @@ -92,7 +92,6 @@ pub mod typed_paths;
98 pub mod utils;
99
100 pub use auth::*;
101- pub use cal::{calendarize, *};
102 pub use help::*;
103 pub use lists::{
104 list, list_candidates, list_edit, list_edit_POST, list_post, list_post_eml, list_post_raw,
105 diff --git a/mailpot-web/src/lists.rs b/mailpot-web/src/lists.rs
106index f9d130e..9791c7a 100644
107--- a/mailpot-web/src/lists.rs
108+++ b/mailpot-web/src/lists.rs
109 @@ -218,7 +218,7 @@ pub async fn list_post(
110 url: ListPath(list.id.to_string().into()).to_crumb(),
111 },
112 Crumb {
113- label: format!("{} {msg_id}", subject_ref).into(),
114+ label: format!("{} <{}>", subject_ref, msg_id.as_str().strip_carets()).into(),
115 url: ListPostPath(list.id.to_string().into(), msg_id.to_string()).to_crumb(),
116 },
117 ];
118 diff --git a/mailpot-web/src/minijinja_utils.rs b/mailpot-web/src/minijinja_utils.rs
119index 5238343..08776fc 100644
120--- a/mailpot-web/src/minijinja_utils.rs
121+++ b/mailpot-web/src/minijinja_utils.rs
122 @@ -19,14 +19,16 @@
123
124 //! Utils for templates with the [`minijinja`] crate.
125
126- use std::fmt::Write;
127-
128- use mailpot::models::ListOwner;
129 pub use mailpot::StripCarets;
130
131 use super::*;
132
133 mod compressed;
134+ mod filters;
135+ mod objects;
136+
137+ pub use filters::*;
138+ pub use objects::*;
139
140 lazy_static::lazy_static! {
141 pub static ref TEMPLATES: Environment<'static> = {
142 @@ -41,7 +43,9 @@ lazy_static::lazy_static! {
143 }
144 add!(function calendarize,
145 strip_carets,
146+ ensure_carets,
147 urlize,
148+ url_encode,
149 heading,
150 topics,
151 login_path,
152 @@ -55,7 +59,8 @@ lazy_static::lazy_static! {
153 list_candidates_path,
154 list_post_path,
155 post_raw_path,
156- post_eml_path
157+ post_eml_path,
158+ post_mbox_path
159 );
160 add!(filter pluralize);
161 // Load compressed templates. They are constructed in build.rs. See
162 @@ -76,818 +81,3 @@ lazy_static::lazy_static! {
163 env
164 };
165 }
166-
167- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
168- pub struct MailingList {
169- pub pk: i64,
170- pub name: String,
171- pub id: String,
172- pub address: String,
173- pub description: Option<String>,
174- pub topics: Vec<String>,
175- #[serde(serialize_with = "super::utils::to_safe_string_opt")]
176- pub archive_url: Option<String>,
177- pub inner: DbVal<mailpot::models::MailingList>,
178- #[serde(default)]
179- pub is_description_html_safe: bool,
180- }
181-
182- impl MailingList {
183- /// Set whether it's safe to not escape the list's description field.
184- ///
185- /// If anyone can display arbitrary html in the server, that's bad.
186- ///
187- /// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
188- /// `ListOwner` slices.
189- pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
190- &mut self,
191- owners: &[O],
192- administrators: &[String],
193- ) {
194- if owners.is_empty() || administrators.is_empty() {
195- return;
196- }
197- self.is_description_html_safe = owners
198- .iter()
199- .any(|o| administrators.contains(&o.borrow().address));
200- }
201- }
202-
203- impl From<DbVal<mailpot::models::MailingList>> for MailingList {
204- fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
205- let DbVal(
206- mailpot::models::MailingList {
207- pk,
208- name,
209- id,
210- address,
211- description,
212- topics,
213- archive_url,
214- },
215- _,
216- ) = val.clone();
217-
218- Self {
219- pk,
220- name,
221- id,
222- address,
223- description,
224- topics,
225- archive_url,
226- inner: val,
227- is_description_html_safe: false,
228- }
229- }
230- }
231-
232- impl std::fmt::Display for MailingList {
233- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
234- self.id.fmt(fmt)
235- }
236- }
237-
238- impl Object for MailingList {
239- fn kind(&self) -> minijinja::value::ObjectKind {
240- minijinja::value::ObjectKind::Struct(self)
241- }
242-
243- fn call_method(
244- &self,
245- _state: &minijinja::State,
246- name: &str,
247- _args: &[Value],
248- ) -> std::result::Result<Value, Error> {
249- match name {
250- "subscription_mailto" => {
251- Ok(Value::from_serializable(&self.inner.subscription_mailto()))
252- }
253- "unsubscription_mailto" => Ok(Value::from_serializable(
254- &self.inner.unsubscription_mailto(),
255- )),
256- "topics" => topics_common(&self.topics),
257- _ => Err(Error::new(
258- minijinja::ErrorKind::UnknownMethod,
259- format!("object has no method named {name}"),
260- )),
261- }
262- }
263- }
264-
265- impl minijinja::value::StructObject for MailingList {
266- fn get_field(&self, name: &str) -> Option<Value> {
267- match name {
268- "pk" => Some(Value::from_serializable(&self.pk)),
269- "name" => Some(Value::from_serializable(&self.name)),
270- "id" => Some(Value::from_serializable(&self.id)),
271- "address" => Some(Value::from_serializable(&self.address)),
272- "description" if self.is_description_html_safe => {
273- self.description.as_ref().map_or_else(
274- || Some(Value::from_serializable(&self.description)),
275- |d| Some(Value::from_safe_string(d.clone())),
276- )
277- }
278- "description" => Some(Value::from_serializable(&self.description)),
279- "topics" => Some(Value::from_serializable(&self.topics)),
280- "archive_url" => Some(Value::from_serializable(&self.archive_url)),
281- "is_description_html_safe" => {
282- Some(Value::from_serializable(&self.is_description_html_safe))
283- }
284- _ => None,
285- }
286- }
287-
288- fn static_fields(&self) -> Option<&'static [&'static str]> {
289- Some(
290- &[
291- "pk",
292- "name",
293- "id",
294- "address",
295- "description",
296- "topics",
297- "archive_url",
298- "is_description_html_safe",
299- ][..],
300- )
301- }
302- }
303-
304- /// Return a vector of weeks, with each week being a vector of 7 days and
305- /// corresponding sum of posts per day.
306- pub fn calendarize(
307- _state: &minijinja::State,
308- args: Value,
309- hists: Value,
310- ) -> std::result::Result<Value, Error> {
311- use chrono::Month;
312-
313- macro_rules! month {
314- ($int:expr) => {{
315- let int = $int;
316- match int {
317- 1 => Month::January.name(),
318- 2 => Month::February.name(),
319- 3 => Month::March.name(),
320- 4 => Month::April.name(),
321- 5 => Month::May.name(),
322- 6 => Month::June.name(),
323- 7 => Month::July.name(),
324- 8 => Month::August.name(),
325- 9 => Month::September.name(),
326- 10 => Month::October.name(),
327- 11 => Month::November.name(),
328- 12 => Month::December.name(),
329- _ => unreachable!(),
330- }
331- }};
332- }
333- let month = args.as_str().unwrap();
334- let hist = hists
335- .get_item(&Value::from(month))?
336- .as_seq()
337- .unwrap()
338- .iter()
339- .map(|v| usize::try_from(v).unwrap())
340- .collect::<Vec<usize>>();
341- let sum: usize = hists
342- .get_item(&Value::from(month))?
343- .as_seq()
344- .unwrap()
345- .iter()
346- .map(|v| usize::try_from(v).unwrap())
347- .sum();
348- let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
349- // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
350- Ok(minijinja::context! {
351- month_name => month!(date.month()),
352- month => month,
353- month_int => date.month() as usize,
354- year => date.year(),
355- weeks => cal::calendarize_with_offset(date, 1),
356- hist => hist,
357- sum,
358- })
359- }
360-
361- /// `pluralize` filter for [`minijinja`].
362- ///
363- /// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
364- /// length `1`. By default, the plural suffix is 's' and the singular suffix is
365- /// empty (''). You can specify a singular suffix as the first argument (or
366- /// `None`, for the default). You can specify a plural suffix as the second
367- /// argument (or `None`, for the default).
368- ///
369- /// See the examples for the correct usage.
370- ///
371- /// # Examples
372- ///
373- /// ```rust,no_run
374- /// # use mailpot_web::pluralize;
375- /// # use minijinja::Environment;
376- ///
377- /// let mut env = Environment::new();
378- /// env.add_filter("pluralize", pluralize);
379- /// for (num, s) in [
380- /// (0, "You have 0 messages."),
381- /// (1, "You have 1 message."),
382- /// (10, "You have 10 messages."),
383- /// ] {
384- /// assert_eq!(
385- /// &env.render_str(
386- /// "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
387- /// minijinja::context! {
388- /// num_messages => num,
389- /// }
390- /// )
391- /// .unwrap(),
392- /// s
393- /// );
394- /// }
395- ///
396- /// for (num, s) in [
397- /// (0, "You have 0 walruses."),
398- /// (1, "You have 1 walrus."),
399- /// (10, "You have 10 walruses."),
400- /// ] {
401- /// assert_eq!(
402- /// &env.render_str(
403- /// r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
404- /// minijinja::context! {
405- /// num_walruses => num,
406- /// }
407- /// )
408- /// .unwrap(),
409- /// s
410- /// );
411- /// }
412- ///
413- /// for (num, s) in [
414- /// (0, "You have 0 cherries."),
415- /// (1, "You have 1 cherry."),
416- /// (10, "You have 10 cherries."),
417- /// ] {
418- /// assert_eq!(
419- /// &env.render_str(
420- /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
421- /// minijinja::context! {
422- /// num_cherries => num,
423- /// }
424- /// )
425- /// .unwrap(),
426- /// s
427- /// );
428- /// }
429- ///
430- /// assert_eq!(
431- /// &env.render_str(
432- /// r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
433- /// minijinja::context! {
434- /// num_cherries => vec![(); 5],
435- /// }
436- /// )
437- /// .unwrap(),
438- /// "You have 5 cherries."
439- /// );
440- ///
441- /// assert_eq!(
442- /// &env.render_str(
443- /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
444- /// minijinja::context! {
445- /// num_cherries => "5",
446- /// }
447- /// )
448- /// .unwrap(),
449- /// "You have 5 cherries."
450- /// );
451- /// assert_eq!(
452- /// &env.render_str(
453- /// r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
454- /// minijinja::context! {
455- /// num_cherries => true,
456- /// }
457- /// )
458- /// .unwrap()
459- /// .to_string(),
460- /// "You have 1 cherry.",
461- /// );
462- /// assert_eq!(
463- /// &env.render_str(
464- /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
465- /// minijinja::context! {
466- /// num_cherries => 0.5f32,
467- /// }
468- /// )
469- /// .unwrap_err()
470- /// .to_string(),
471- /// "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
472- /// length but of type number (in <string>:1)",
473- /// );
474- /// ```
475- pub fn pluralize(
476- v: Value,
477- singular: Option<String>,
478- plural: Option<String>,
479- ) -> Result<Value, minijinja::Error> {
480- macro_rules! int_try_from {
481- ($ty:ty) => {
482- <$ty>::try_from(v.clone()).ok().map(|v| v != 1)
483- };
484- ($fty:ty, $($ty:ty),*) => {
485- int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
486- }
487- }
488- let is_plural: bool = v
489- .as_str()
490- .and_then(|s| s.parse::<i128>().ok())
491- .map(|l| l != 1)
492- .or_else(|| v.len().map(|l| l != 1))
493- .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
494- .ok_or_else(|| {
495- minijinja::Error::new(
496- minijinja::ErrorKind::InvalidOperation,
497- format!(
498- "Pluralize argument is not an integer, or a sequence / object with a length \
499- but of type {}",
500- v.kind()
501- ),
502- )
503- })?;
504- Ok(match (is_plural, singular, plural) {
505- (false, None, _) => "".into(),
506- (false, Some(suffix), _) => suffix.into(),
507- (true, _, None) => "s".into(),
508- (true, _, Some(suffix)) => suffix.into(),
509- })
510- }
511-
512- /// `strip_carets` filter for [`minijinja`].
513- ///
514- /// Removes `[<>]` from message ids.
515- pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
516- Ok(Value::from(
517- arg.as_str()
518- .ok_or_else(|| {
519- minijinja::Error::new(
520- minijinja::ErrorKind::InvalidOperation,
521- format!("argument to strip_carets() is of type {}", arg.kind()),
522- )
523- })?
524- .strip_carets(),
525- ))
526- }
527-
528- /// `urlize` filter for [`minijinja`].
529- ///
530- /// Returns a safe string for use in `<a href=..` attributes.
531- ///
532- /// # Examples
533- ///
534- /// ```rust,no_run
535- /// # use mailpot_web::urlize;
536- /// # use minijinja::Environment;
537- /// # use minijinja::value::Value;
538- ///
539- /// let mut env = Environment::new();
540- /// env.add_function("urlize", urlize);
541- /// env.add_global(
542- /// "root_url_prefix",
543- /// Value::from_safe_string("/lists/prefix/".to_string()),
544- /// );
545- /// assert_eq!(
546- /// &env.render_str(
547- /// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
548- /// minijinja::context! {}
549- /// )
550- /// .unwrap(),
551- /// "<a href=\"/lists/prefix/path/index.html\">link</a>",
552- /// );
553- /// ```
554- pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
555- let Some(prefix) = state.lookup("root_url_prefix") else {
556- return Ok(arg);
557- };
558- Ok(Value::from_safe_string(format!("{prefix}{arg}")))
559- }
560-
561- /// Make an html heading: `h1, h2, h3` etc.
562- ///
563- /// # Example
564- /// ```rust,no_run
565- /// use mailpot_web::minijinja_utils::heading;
566- /// use minijinja::value::Value;
567- ///
568- /// assert_eq!(
569- /// "<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>",
570- /// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
571- /// );
572- /// assert_eq!(
573- /// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
574- /// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
575- /// );
576- /// assert_eq!(
577- /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
578- /// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
579- /// );
580- /// assert_eq!(
581- /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
582- /// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
583- /// );
584- /// assert_eq!(
585- /// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
586- /// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
587- /// );
588- /// ```
589- pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
590- use convert_case::{Case, Casing};
591- macro_rules! test {
592- () => {
593- |n| *n > 0 && *n < 7
594- };
595- }
596-
597- macro_rules! int_try_from {
598- ($ty:ty) => {
599- <$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
600- };
601- ($fty:ty, $($ty:ty),*) => {
602- int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
603- }
604- }
605- let level: u8 = level
606- .as_str()
607- .and_then(|s| s.parse::<i128>().ok())
608- .filter(test! {})
609- .map(|n| n as u8)
610- .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
611- .ok_or_else(|| {
612- if matches!(level.kind(), minijinja::value::ValueKind::Number) {
613- minijinja::Error::new(
614- minijinja::ErrorKind::InvalidOperation,
615- "first heading() argument must be an unsigned integer less than 7 and positive",
616- )
617- } else {
618- minijinja::Error::new(
619- minijinja::ErrorKind::InvalidOperation,
620- format!(
621- "first heading() argument is not an integer < 7 but of type {}",
622- level.kind()
623- ),
624- )
625- }
626- })?;
627- let text = text.as_str().ok_or_else(|| {
628- minijinja::Error::new(
629- minijinja::ErrorKind::InvalidOperation,
630- format!(
631- "second heading() argument is not a string but of type {}",
632- text.kind()
633- ),
634- )
635- })?;
636- if let Some(v) = id {
637- let kebab = v.as_str().ok_or_else(|| {
638- minijinja::Error::new(
639- minijinja::ErrorKind::InvalidOperation,
640- format!(
641- "third heading() argument is not a string but of type {}",
642- v.kind()
643- ),
644- )
645- })?;
646- Ok(Value::from_safe_string(format!(
647- "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
648- href=\"#{kebab}\"></a></h{level}>"
649- )))
650- } else {
651- let kebab_v = text.to_case(Case::Kebab);
652- let kebab =
653- percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
654- Ok(Value::from_safe_string(format!(
655- "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
656- href=\"#{kebab}\"></a></h{level}>"
657- )))
658- }
659- }
660-
661- /// Make an array of topic strings into html badges.
662- ///
663- /// # Example
664- /// ```rust
665- /// use mailpot_web::minijinja_utils::topics;
666- /// use minijinja::value::Value;
667- ///
668- /// let v: Value = topics(Value::from_serializable(&vec![
669- /// "a".to_string(),
670- /// "aab".to_string(),
671- /// "aaab".to_string(),
672- /// ]))
673- /// .unwrap();
674- /// assert_eq!(
675- /// "<ul class=\"tags\"><li class=\"tag\" style=\"--red:110;--green:120;--blue:180;\"><span \
676- /// class=\"tag-name\"><a href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
677- /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
678- /// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
679- /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
680- /// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
681- /// &v.to_string()
682- /// );
683- /// ```
684- pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
685- topics.try_iter()?;
686- let topics: Vec<String> = topics
687- .try_iter()?
688- .map(|v| v.to_string())
689- .collect::<Vec<String>>();
690- topics_common(&topics)
691- }
692-
693- pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
694- let mut ul = String::new();
695- write!(&mut ul, r#"<ul class="tags">"#)?;
696- for topic in topics {
697- write!(
698- &mut ul,
699- r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
700- )?;
701- write!(&mut ul, "{}", TopicsPath)?;
702- write!(&mut ul, r#"?query="#)?;
703- write!(
704- &mut ul,
705- "{}",
706- utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
707- )?;
708- write!(&mut ul, r#"">"#)?;
709- write!(&mut ul, "{}", topic)?;
710- write!(&mut ul, r#"</a></span></li>"#)?;
711- }
712- write!(&mut ul, r#"</ul>"#)?;
713- Ok(Value::from_safe_string(ul))
714- }
715-
716- #[cfg(test)]
717- mod tests {
718- use super::*;
719-
720- #[test]
721- fn test_pluralize() {
722- let mut env = Environment::new();
723- env.add_filter("pluralize", pluralize);
724- for (num, s) in [
725- (0, "You have 0 messages."),
726- (1, "You have 1 message."),
727- (10, "You have 10 messages."),
728- ] {
729- assert_eq!(
730- &env.render_str(
731- "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
732- minijinja::context! {
733- num_messages => num,
734- }
735- )
736- .unwrap(),
737- s
738- );
739- }
740-
741- for (num, s) in [
742- (0, "You have 0 walruses."),
743- (1, "You have 1 walrus."),
744- (10, "You have 10 walruses."),
745- ] {
746- assert_eq!(
747- &env.render_str(
748- r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
749- minijinja::context! {
750- num_walruses => num,
751- }
752- )
753- .unwrap(),
754- s
755- );
756- }
757-
758- for (num, s) in [
759- (0, "You have 0 cherries."),
760- (1, "You have 1 cherry."),
761- (10, "You have 10 cherries."),
762- ] {
763- assert_eq!(
764- &env.render_str(
765- r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
766- minijinja::context! {
767- num_cherries => num,
768- }
769- )
770- .unwrap(),
771- s
772- );
773- }
774-
775- assert_eq!(
776- &env.render_str(
777- r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
778- minijinja::context! {
779- num_cherries => vec![(); 5],
780- }
781- )
782- .unwrap(),
783- "You have 5 cherries."
784- );
785-
786- assert_eq!(
787- &env.render_str(
788- r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
789- minijinja::context! {
790- num_cherries => "5",
791- }
792- )
793- .unwrap(),
794- "You have 5 cherries."
795- );
796- assert_eq!(
797- &env.render_str(
798- r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
799- minijinja::context! {
800- num_cherries => true,
801- }
802- )
803- .unwrap(),
804- "You have 1 cherry.",
805- );
806- assert_eq!(
807- &env.render_str(
808- r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
809- minijinja::context! {
810- num_cherries => 0.5f32,
811- }
812- )
813- .unwrap_err()
814- .to_string(),
815- "invalid operation: Pluralize argument is not an integer, or a sequence / object with \
816- a length but of type number (in <string>:1)",
817- );
818- }
819-
820- #[test]
821- fn test_urlize() {
822- let mut env = Environment::new();
823- env.add_function("urlize", urlize);
824- env.add_global(
825- "root_url_prefix",
826- Value::from_safe_string("/lists/prefix/".to_string()),
827- );
828- assert_eq!(
829- &env.render_str(
830- "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
831- minijinja::context! {}
832- )
833- .unwrap(),
834- "<a href=\"/lists/prefix/path/index.html\">link</a>",
835- );
836- }
837-
838- #[test]
839- fn test_heading() {
840- assert_eq!(
841- "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a \
842- class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
843- &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None)
844- .unwrap()
845- .to_string()
846- );
847- assert_eq!(
848- "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" \
849- href=\"#short\"></a></h2>",
850- &heading(
851- 2.into(),
852- "bl bfa B AH bAsdb hadas d".into(),
853- Some("short".into())
854- )
855- .unwrap()
856- .to_string()
857- );
858- assert_eq!(
859- r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
860- &heading(
861- 0.into(),
862- "bl bfa B AH bAsdb hadas d".into(),
863- Some("short".into())
864- )
865- .unwrap_err()
866- .to_string()
867- );
868- assert_eq!(
869- r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
870- &heading(
871- 8.into(),
872- "bl bfa B AH bAsdb hadas d".into(),
873- Some("short".into())
874- )
875- .unwrap_err()
876- .to_string()
877- );
878- assert_eq!(
879- r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
880- &heading(
881- Value::from(vec![Value::from(1)]),
882- "bl bfa B AH bAsdb hadas d".into(),
883- Some("short".into())
884- )
885- .unwrap_err()
886- .to_string()
887- );
888- }
889-
890- #[test]
891- fn test_strip_carets() {
892- let mut env = Environment::new();
893- env.add_filter("strip_carets", strip_carets);
894- assert_eq!(
895- &env.render_str(
896- "{{ msg_id | strip_carets }}",
897- minijinja::context! {
898- msg_id => "<hello1@example.com>",
899- }
900- )
901- .unwrap(),
902- "hello1@example.com",
903- );
904- }
905-
906- #[test]
907- fn test_calendarize() {
908- use std::collections::HashMap;
909-
910- let mut env = Environment::new();
911- env.add_function("calendarize", calendarize);
912-
913- let month = "2001-09";
914- let mut hist = [0usize; 31];
915- hist[15] = 5;
916- hist[1] = 1;
917- hist[0] = 512;
918- hist[30] = 30;
919- assert_eq!(
920- &env.render_str(
921- "{% set c=calendarize(month, hists) %}Month: {{ c.month }} Month Name: {{ \
922- c.month_name }} Month Int: {{ c.month_int }} Year: {{ c.year }} Sum: {{ c.sum }} {% \
923- for week in c.weeks %}{% for day in week %}{% set num = c.hist[day-1] %}({{ day }}, \
924- {{ num }}){% endfor %}{% endfor %}",
925- minijinja::context! {
926- month,
927- hists => vec![(month.to_string(), hist)].into_iter().collect::<HashMap<String, [usize;
928- 31]>>(),
929- }
930- )
931- .unwrap(),
932- "Month: 2001-09 Month Name: September Month Int: 9 Year: 2001 Sum: 548 (0, 30)(0, 30)(0, \
933- 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, \
934- 0)(12, 0)(13, 0)(14, 0)(15, 0)(16, 5)(17, 0)(18, 0)(19, 0)(20, 0)(21, 0)(22, 0)(23, \
935- 0)(24, 0)(25, 0)(26, 0)(27, 0)(28, 0)(29, 0)(30, 0)"
936- );
937- }
938-
939- #[test]
940- fn test_list_html_safe() {
941- let mut list = MailingList {
942- pk: 0,
943- name: String::new(),
944- id: String::new(),
945- address: String::new(),
946- description: None,
947- topics: vec![],
948- archive_url: None,
949- inner: DbVal(
950- mailpot::models::MailingList {
951- pk: 0,
952- name: String::new(),
953- id: String::new(),
954- address: String::new(),
955- description: None,
956- topics: vec![],
957- archive_url: None,
958- },
959- 0,
960- ),
961- is_description_html_safe: false,
962- };
963-
964- let mut list_owners = vec![ListOwner {
965- pk: 0,
966- list: 0,
967- address: "admin@example.com".to_string(),
968- name: None,
969- }];
970- let administrators = vec!["admin@example.com".to_string()];
971- list.set_safety(&list_owners, &administrators);
972- assert!(list.is_description_html_safe);
973- list.set_safety::<ListOwner>(&[], &[]);
974- assert!(list.is_description_html_safe);
975- list.is_description_html_safe = false;
976- list_owners[0].address = "user@example.com".to_string();
977- list.set_safety(&list_owners, &administrators);
978- assert!(!list.is_description_html_safe);
979- }
980- }
981 diff --git a/mailpot-web/src/minijinja_utils/compressed.rs b/mailpot-web/src/minijinja_utils/compressed.rs
982index 8965d02..a347c8b 100644
983--- a/mailpot-web/src/minijinja_utils/compressed.rs
984+++ b/mailpot-web/src/minijinja_utils/compressed.rs
985 @@ -17,4 +17,8 @@
986 * along with this program. If not, see <https://www.gnu.org/licenses/>.
987 */
988
989+ //[tag:embed_templates]
990+ /// This is an array of all templates compressed for smaller binary size.
991+ ///
992+ /// Compression happens at compile-time in the `build.rs` script.
993 pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data");
994 diff --git a/mailpot-web/src/minijinja_utils/filters.rs b/mailpot-web/src/minijinja_utils/filters.rs
995new file mode 100644
996index 0000000..46e93ed
997--- /dev/null
998+++ b/mailpot-web/src/minijinja_utils/filters.rs
999 @@ -0,0 +1,743 @@
1000+ /*
1001+ * This file is part of mailpot
1002+ *
1003+ * Copyright 2020 - Manos Pitsidianakis
1004+ *
1005+ * This program is free software: you can redistribute it and/or modify
1006+ * it under the terms of the GNU Affero General Public License as
1007+ * published by the Free Software Foundation, either version 3 of the
1008+ * License, or (at your option) any later version.
1009+ *
1010+ * This program is distributed in the hope that it will be useful,
1011+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1012+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1013+ * GNU Affero General Public License for more details.
1014+ *
1015+ * You should have received a copy of the GNU Affero General Public License
1016+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
1017+ */
1018+
1019+ //! Utils for templates with the [`minijinja`] crate.
1020+
1021+ use std::fmt::Write;
1022+
1023+ pub use mailpot::StripCarets;
1024+
1025+ use super::*;
1026+
1027+ /// Return a vector of weeks, with each week being a vector of 7 days and
1028+ /// corresponding sum of posts per day.
1029+ pub fn calendarize(
1030+ _state: &minijinja::State,
1031+ args: Value,
1032+ hists: Value,
1033+ ) -> std::result::Result<Value, Error> {
1034+ use chrono::Month;
1035+
1036+ macro_rules! month {
1037+ ($int:expr) => {{
1038+ let int = $int;
1039+ match int {
1040+ 1 => Month::January.name(),
1041+ 2 => Month::February.name(),
1042+ 3 => Month::March.name(),
1043+ 4 => Month::April.name(),
1044+ 5 => Month::May.name(),
1045+ 6 => Month::June.name(),
1046+ 7 => Month::July.name(),
1047+ 8 => Month::August.name(),
1048+ 9 => Month::September.name(),
1049+ 10 => Month::October.name(),
1050+ 11 => Month::November.name(),
1051+ 12 => Month::December.name(),
1052+ _ => unreachable!(),
1053+ }
1054+ }};
1055+ }
1056+ let month = args.as_str().unwrap();
1057+ let hist = hists
1058+ .get_item(&Value::from(month))?
1059+ .as_seq()
1060+ .unwrap()
1061+ .iter()
1062+ .map(|v| usize::try_from(v).unwrap())
1063+ .collect::<Vec<usize>>();
1064+ let sum: usize = hists
1065+ .get_item(&Value::from(month))?
1066+ .as_seq()
1067+ .unwrap()
1068+ .iter()
1069+ .map(|v| usize::try_from(v).unwrap())
1070+ .sum();
1071+ let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
1072+ // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
1073+ Ok(minijinja::context! {
1074+ month_name => month!(date.month()),
1075+ month => month,
1076+ month_int => date.month() as usize,
1077+ year => date.year(),
1078+ weeks => cal::calendarize_with_offset(date, 1),
1079+ hist => hist,
1080+ sum,
1081+ })
1082+ }
1083+
1084+ /// `pluralize` filter for [`minijinja`].
1085+ ///
1086+ /// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
1087+ /// length `1`. By default, the plural suffix is 's' and the singular suffix is
1088+ /// empty (''). You can specify a singular suffix as the first argument (or
1089+ /// `None`, for the default). You can specify a plural suffix as the second
1090+ /// argument (or `None`, for the default).
1091+ ///
1092+ /// See the examples for the correct usage.
1093+ ///
1094+ /// # Examples
1095+ ///
1096+ /// ```rust,no_run
1097+ /// # use mailpot_web::pluralize;
1098+ /// # use minijinja::Environment;
1099+ ///
1100+ /// let mut env = Environment::new();
1101+ /// env.add_filter("pluralize", pluralize);
1102+ /// for (num, s) in [
1103+ /// (0, "You have 0 messages."),
1104+ /// (1, "You have 1 message."),
1105+ /// (10, "You have 10 messages."),
1106+ /// ] {
1107+ /// assert_eq!(
1108+ /// &env.render_str(
1109+ /// "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
1110+ /// minijinja::context! {
1111+ /// num_messages => num,
1112+ /// }
1113+ /// )
1114+ /// .unwrap(),
1115+ /// s
1116+ /// );
1117+ /// }
1118+ ///
1119+ /// for (num, s) in [
1120+ /// (0, "You have 0 walruses."),
1121+ /// (1, "You have 1 walrus."),
1122+ /// (10, "You have 10 walruses."),
1123+ /// ] {
1124+ /// assert_eq!(
1125+ /// &env.render_str(
1126+ /// r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
1127+ /// minijinja::context! {
1128+ /// num_walruses => num,
1129+ /// }
1130+ /// )
1131+ /// .unwrap(),
1132+ /// s
1133+ /// );
1134+ /// }
1135+ ///
1136+ /// for (num, s) in [
1137+ /// (0, "You have 0 cherries."),
1138+ /// (1, "You have 1 cherry."),
1139+ /// (10, "You have 10 cherries."),
1140+ /// ] {
1141+ /// assert_eq!(
1142+ /// &env.render_str(
1143+ /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1144+ /// minijinja::context! {
1145+ /// num_cherries => num,
1146+ /// }
1147+ /// )
1148+ /// .unwrap(),
1149+ /// s
1150+ /// );
1151+ /// }
1152+ ///
1153+ /// assert_eq!(
1154+ /// &env.render_str(
1155+ /// r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1156+ /// minijinja::context! {
1157+ /// num_cherries => vec![(); 5],
1158+ /// }
1159+ /// )
1160+ /// .unwrap(),
1161+ /// "You have 5 cherries."
1162+ /// );
1163+ ///
1164+ /// assert_eq!(
1165+ /// &env.render_str(
1166+ /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1167+ /// minijinja::context! {
1168+ /// num_cherries => "5",
1169+ /// }
1170+ /// )
1171+ /// .unwrap(),
1172+ /// "You have 5 cherries."
1173+ /// );
1174+ /// assert_eq!(
1175+ /// &env.render_str(
1176+ /// r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1177+ /// minijinja::context! {
1178+ /// num_cherries => true,
1179+ /// }
1180+ /// )
1181+ /// .unwrap()
1182+ /// .to_string(),
1183+ /// "You have 1 cherry.",
1184+ /// );
1185+ /// assert_eq!(
1186+ /// &env.render_str(
1187+ /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1188+ /// minijinja::context! {
1189+ /// num_cherries => 0.5f32,
1190+ /// }
1191+ /// )
1192+ /// .unwrap_err()
1193+ /// .to_string(),
1194+ /// "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
1195+ /// length but of type number (in <string>:1)",
1196+ /// );
1197+ /// ```
1198+ pub fn pluralize(
1199+ v: Value,
1200+ singular: Option<String>,
1201+ plural: Option<String>,
1202+ ) -> Result<Value, minijinja::Error> {
1203+ macro_rules! int_try_from {
1204+ ($ty:ty) => {
1205+ <$ty>::try_from(v.clone()).ok().map(|v| v != 1)
1206+ };
1207+ ($fty:ty, $($ty:ty),*) => {
1208+ int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
1209+ }
1210+ }
1211+ let is_plural: bool = v
1212+ .as_str()
1213+ .and_then(|s| s.parse::<i128>().ok())
1214+ .map(|l| l != 1)
1215+ .or_else(|| v.len().map(|l| l != 1))
1216+ .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
1217+ .ok_or_else(|| {
1218+ minijinja::Error::new(
1219+ minijinja::ErrorKind::InvalidOperation,
1220+ format!(
1221+ "Pluralize argument is not an integer, or a sequence / object with a length \
1222+ but of type {}",
1223+ v.kind()
1224+ ),
1225+ )
1226+ })?;
1227+ Ok(match (is_plural, singular, plural) {
1228+ (false, None, _) => "".into(),
1229+ (false, Some(suffix), _) => suffix.into(),
1230+ (true, _, None) => "s".into(),
1231+ (true, _, Some(suffix)) => suffix.into(),
1232+ })
1233+ }
1234+
1235+ /// `strip_carets` filter for [`minijinja`].
1236+ ///
1237+ /// Removes `<`, `>` from message ids.
1238+ pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
1239+ Ok(Value::from(
1240+ arg.as_str()
1241+ .ok_or_else(|| {
1242+ minijinja::Error::new(
1243+ minijinja::ErrorKind::InvalidOperation,
1244+ format!("argument to strip_carets() is of type {}", arg.kind()),
1245+ )
1246+ })?
1247+ .strip_carets(),
1248+ ))
1249+ }
1250+
1251+ /// `ensure_carets` filter for [`minijinja`].
1252+ ///
1253+ /// Makes sure message id value is surrounded by carets `<', `>`.
1254+ pub fn ensure_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
1255+ Ok({
1256+ let s = arg.as_str().ok_or_else(|| {
1257+ minijinja::Error::new(
1258+ minijinja::ErrorKind::InvalidOperation,
1259+ format!("argument to ensure_carets() is of type {}", arg.kind()),
1260+ )
1261+ })?;
1262+ if !s.trim().starts_with('<') && !s.ends_with('>') {
1263+ Value::from(format!("<{s}>"))
1264+ } else {
1265+ Value::from(s)
1266+ }
1267+ })
1268+ }
1269+
1270+ /// `urlize` filter for [`minijinja`].
1271+ ///
1272+ /// Returns a safe string for use in `<a href=..` attributes.
1273+ ///
1274+ /// # Examples
1275+ ///
1276+ /// ```rust,no_run
1277+ /// # use mailpot_web::urlize;
1278+ /// # use minijinja::Environment;
1279+ /// # use minijinja::value::Value;
1280+ ///
1281+ /// let mut env = Environment::new();
1282+ /// env.add_function("urlize", urlize);
1283+ /// env.add_global(
1284+ /// "root_url_prefix",
1285+ /// Value::from_safe_string("/lists/prefix/".to_string()),
1286+ /// );
1287+ /// assert_eq!(
1288+ /// &env.render_str(
1289+ /// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
1290+ /// minijinja::context! {}
1291+ /// )
1292+ /// .unwrap(),
1293+ /// "<a href=\"/lists/prefix/path/index.html\">link</a>",
1294+ /// );
1295+ /// ```
1296+ pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
1297+ let Some(prefix) = state.lookup("root_url_prefix") else {
1298+ return Ok(arg);
1299+ };
1300+ Ok(Value::from_safe_string(format!("{prefix}{arg}")))
1301+ }
1302+
1303+ pub fn url_encode(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
1304+ Ok(Value::from_safe_string(
1305+ utf8_percent_encode(
1306+ arg.as_str().ok_or_else(|| {
1307+ minijinja::Error::new(
1308+ minijinja::ErrorKind::InvalidOperation,
1309+ format!(
1310+ "url_decode() argument is not a string but of type {}",
1311+ arg.kind()
1312+ ),
1313+ )
1314+ })?,
1315+ crate::typed_paths::PATH_SEGMENT,
1316+ )
1317+ .to_string(),
1318+ ))
1319+ }
1320+
1321+ /// Make an html heading: `h1, h2, h3` etc.
1322+ ///
1323+ /// # Example
1324+ /// ```rust,no_run
1325+ /// use mailpot_web::minijinja_utils::heading;
1326+ /// use minijinja::value::Value;
1327+ ///
1328+ /// assert_eq!(
1329+ /// "<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>",
1330+ /// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
1331+ /// );
1332+ /// assert_eq!(
1333+ /// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
1334+ /// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
1335+ /// );
1336+ /// assert_eq!(
1337+ /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
1338+ /// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
1339+ /// );
1340+ /// assert_eq!(
1341+ /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
1342+ /// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
1343+ /// );
1344+ /// assert_eq!(
1345+ /// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
1346+ /// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
1347+ /// );
1348+ /// ```
1349+ pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
1350+ use convert_case::{Case, Casing};
1351+ macro_rules! test {
1352+ () => {
1353+ |n| *n > 0 && *n < 7
1354+ };
1355+ }
1356+
1357+ macro_rules! int_try_from {
1358+ ($ty:ty) => {
1359+ <$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
1360+ };
1361+ ($fty:ty, $($ty:ty),*) => {
1362+ int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
1363+ }
1364+ }
1365+ let level: u8 = level
1366+ .as_str()
1367+ .and_then(|s| s.parse::<i128>().ok())
1368+ .filter(test! {})
1369+ .map(|n| n as u8)
1370+ .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
1371+ .ok_or_else(|| {
1372+ if matches!(level.kind(), minijinja::value::ValueKind::Number) {
1373+ minijinja::Error::new(
1374+ minijinja::ErrorKind::InvalidOperation,
1375+ "first heading() argument must be an unsigned integer less than 7 and positive",
1376+ )
1377+ } else {
1378+ minijinja::Error::new(
1379+ minijinja::ErrorKind::InvalidOperation,
1380+ format!(
1381+ "first heading() argument is not an integer < 7 but of type {}",
1382+ level.kind()
1383+ ),
1384+ )
1385+ }
1386+ })?;
1387+ let text = text.as_str().ok_or_else(|| {
1388+ minijinja::Error::new(
1389+ minijinja::ErrorKind::InvalidOperation,
1390+ format!(
1391+ "second heading() argument is not a string but of type {}",
1392+ text.kind()
1393+ ),
1394+ )
1395+ })?;
1396+ if let Some(v) = id {
1397+ let kebab = v.as_str().ok_or_else(|| {
1398+ minijinja::Error::new(
1399+ minijinja::ErrorKind::InvalidOperation,
1400+ format!(
1401+ "third heading() argument is not a string but of type {}",
1402+ v.kind()
1403+ ),
1404+ )
1405+ })?;
1406+ Ok(Value::from_safe_string(format!(
1407+ "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
1408+ href=\"#{kebab}\"></a></h{level}>"
1409+ )))
1410+ } else {
1411+ let kebab_v = text.to_case(Case::Kebab);
1412+ let kebab =
1413+ percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
1414+ Ok(Value::from_safe_string(format!(
1415+ "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
1416+ href=\"#{kebab}\"></a></h{level}>"
1417+ )))
1418+ }
1419+ }
1420+
1421+ /// Make an array of topic strings into html badges.
1422+ ///
1423+ /// # Example
1424+ /// ```rust
1425+ /// use mailpot_web::minijinja_utils::topics;
1426+ /// use minijinja::value::Value;
1427+ ///
1428+ /// let v: Value = topics(Value::from_serializable(&vec![
1429+ /// "a".to_string(),
1430+ /// "aab".to_string(),
1431+ /// "aaab".to_string(),
1432+ /// ]))
1433+ /// .unwrap();
1434+ /// assert_eq!(
1435+ /// "<ul class=\"tags\"><li class=\"tag\" style=\"--red:110;--green:120;--blue:180;\"><span \
1436+ /// class=\"tag-name\"><a href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
1437+ /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
1438+ /// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
1439+ /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
1440+ /// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
1441+ /// &v.to_string()
1442+ /// );
1443+ /// ```
1444+ pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
1445+ topics.try_iter()?;
1446+ let topics: Vec<String> = topics
1447+ .try_iter()?
1448+ .map(|v| v.to_string())
1449+ .collect::<Vec<String>>();
1450+ topics_common(&topics)
1451+ }
1452+
1453+ pub fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
1454+ let mut ul = String::new();
1455+ write!(&mut ul, r#"<ul class="tags">"#)?;
1456+ for topic in topics {
1457+ write!(
1458+ &mut ul,
1459+ r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
1460+ )?;
1461+ write!(&mut ul, "{}", TopicsPath)?;
1462+ write!(&mut ul, r#"?query="#)?;
1463+ write!(
1464+ &mut ul,
1465+ "{}",
1466+ utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
1467+ )?;
1468+ write!(&mut ul, r#"">"#)?;
1469+ write!(&mut ul, "{}", topic)?;
1470+ write!(&mut ul, r#"</a></span></li>"#)?;
1471+ }
1472+ write!(&mut ul, r#"</ul>"#)?;
1473+ Ok(Value::from_safe_string(ul))
1474+ }
1475+
1476+ #[cfg(test)]
1477+ mod tests {
1478+ use mailpot::models::ListOwner;
1479+
1480+ use super::*;
1481+
1482+ #[test]
1483+ fn test_pluralize() {
1484+ let mut env = Environment::new();
1485+ env.add_filter("pluralize", pluralize);
1486+ for (num, s) in [
1487+ (0, "You have 0 messages."),
1488+ (1, "You have 1 message."),
1489+ (10, "You have 10 messages."),
1490+ ] {
1491+ assert_eq!(
1492+ &env.render_str(
1493+ "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
1494+ minijinja::context! {
1495+ num_messages => num,
1496+ }
1497+ )
1498+ .unwrap(),
1499+ s
1500+ );
1501+ }
1502+
1503+ for (num, s) in [
1504+ (0, "You have 0 walruses."),
1505+ (1, "You have 1 walrus."),
1506+ (10, "You have 10 walruses."),
1507+ ] {
1508+ assert_eq!(
1509+ &env.render_str(
1510+ r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
1511+ minijinja::context! {
1512+ num_walruses => num,
1513+ }
1514+ )
1515+ .unwrap(),
1516+ s
1517+ );
1518+ }
1519+
1520+ for (num, s) in [
1521+ (0, "You have 0 cherries."),
1522+ (1, "You have 1 cherry."),
1523+ (10, "You have 10 cherries."),
1524+ ] {
1525+ assert_eq!(
1526+ &env.render_str(
1527+ r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1528+ minijinja::context! {
1529+ num_cherries => num,
1530+ }
1531+ )
1532+ .unwrap(),
1533+ s
1534+ );
1535+ }
1536+
1537+ assert_eq!(
1538+ &env.render_str(
1539+ r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1540+ minijinja::context! {
1541+ num_cherries => vec![(); 5],
1542+ }
1543+ )
1544+ .unwrap(),
1545+ "You have 5 cherries."
1546+ );
1547+
1548+ assert_eq!(
1549+ &env.render_str(
1550+ r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1551+ minijinja::context! {
1552+ num_cherries => "5",
1553+ }
1554+ )
1555+ .unwrap(),
1556+ "You have 5 cherries."
1557+ );
1558+ assert_eq!(
1559+ &env.render_str(
1560+ r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1561+ minijinja::context! {
1562+ num_cherries => true,
1563+ }
1564+ )
1565+ .unwrap(),
1566+ "You have 1 cherry.",
1567+ );
1568+ assert_eq!(
1569+ &env.render_str(
1570+ r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
1571+ minijinja::context! {
1572+ num_cherries => 0.5f32,
1573+ }
1574+ )
1575+ .unwrap_err()
1576+ .to_string(),
1577+ "invalid operation: Pluralize argument is not an integer, or a sequence / object with \
1578+ a length but of type number (in <string>:1)",
1579+ );
1580+ }
1581+
1582+ #[test]
1583+ fn test_urlize() {
1584+ let mut env = Environment::new();
1585+ env.add_function("urlize", urlize);
1586+ env.add_global(
1587+ "root_url_prefix",
1588+ Value::from_safe_string("/lists/prefix/".to_string()),
1589+ );
1590+ assert_eq!(
1591+ &env.render_str(
1592+ "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
1593+ minijinja::context! {}
1594+ )
1595+ .unwrap(),
1596+ "<a href=\"/lists/prefix/path/index.html\">link</a>",
1597+ );
1598+ }
1599+
1600+ #[test]
1601+ fn test_heading() {
1602+ assert_eq!(
1603+ "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a \
1604+ class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
1605+ &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None)
1606+ .unwrap()
1607+ .to_string()
1608+ );
1609+ assert_eq!(
1610+ "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" \
1611+ href=\"#short\"></a></h2>",
1612+ &heading(
1613+ 2.into(),
1614+ "bl bfa B AH bAsdb hadas d".into(),
1615+ Some("short".into())
1616+ )
1617+ .unwrap()
1618+ .to_string()
1619+ );
1620+ assert_eq!(
1621+ r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
1622+ &heading(
1623+ 0.into(),
1624+ "bl bfa B AH bAsdb hadas d".into(),
1625+ Some("short".into())
1626+ )
1627+ .unwrap_err()
1628+ .to_string()
1629+ );
1630+ assert_eq!(
1631+ r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
1632+ &heading(
1633+ 8.into(),
1634+ "bl bfa B AH bAsdb hadas d".into(),
1635+ Some("short".into())
1636+ )
1637+ .unwrap_err()
1638+ .to_string()
1639+ );
1640+ assert_eq!(
1641+ r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
1642+ &heading(
1643+ Value::from(vec![Value::from(1)]),
1644+ "bl bfa B AH bAsdb hadas d".into(),
1645+ Some("short".into())
1646+ )
1647+ .unwrap_err()
1648+ .to_string()
1649+ );
1650+ }
1651+
1652+ #[test]
1653+ fn test_strip_carets() {
1654+ let mut env = Environment::new();
1655+ env.add_filter("strip_carets", strip_carets);
1656+ assert_eq!(
1657+ &env.render_str(
1658+ "{{ msg_id | strip_carets }}",
1659+ minijinja::context! {
1660+ msg_id => "<hello1@example.com>",
1661+ }
1662+ )
1663+ .unwrap(),
1664+ "hello1@example.com",
1665+ );
1666+ }
1667+
1668+ #[test]
1669+ fn test_calendarize() {
1670+ use std::collections::HashMap;
1671+
1672+ let mut env = Environment::new();
1673+ env.add_function("calendarize", calendarize);
1674+
1675+ let month = "2001-09";
1676+ let mut hist = [0usize; 31];
1677+ hist[15] = 5;
1678+ hist[1] = 1;
1679+ hist[0] = 512;
1680+ hist[30] = 30;
1681+ assert_eq!(
1682+ &env.render_str(
1683+ "{% set c=calendarize(month, hists) %}Month: {{ c.month }} Month Name: {{ \
1684+ c.month_name }} Month Int: {{ c.month_int }} Year: {{ c.year }} Sum: {{ c.sum }} {% \
1685+ for week in c.weeks %}{% for day in week %}{% set num = c.hist[day-1] %}({{ day }}, \
1686+ {{ num }}){% endfor %}{% endfor %}",
1687+ minijinja::context! {
1688+ month,
1689+ hists => vec![(month.to_string(), hist)].into_iter().collect::<HashMap<String, [usize;
1690+ 31]>>(),
1691+ }
1692+ )
1693+ .unwrap(),
1694+ "Month: 2001-09 Month Name: September Month Int: 9 Year: 2001 Sum: 548 (0, 30)(0, 30)(0, \
1695+ 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, \
1696+ 0)(12, 0)(13, 0)(14, 0)(15, 0)(16, 5)(17, 0)(18, 0)(19, 0)(20, 0)(21, 0)(22, 0)(23, \
1697+ 0)(24, 0)(25, 0)(26, 0)(27, 0)(28, 0)(29, 0)(30, 0)"
1698+ );
1699+ }
1700+
1701+ #[test]
1702+ fn test_list_html_safe() {
1703+ let mut list = MailingList {
1704+ pk: 0,
1705+ name: String::new(),
1706+ id: String::new(),
1707+ address: String::new(),
1708+ description: None,
1709+ topics: vec![],
1710+ archive_url: None,
1711+ inner: DbVal(
1712+ mailpot::models::MailingList {
1713+ pk: 0,
1714+ name: String::new(),
1715+ id: String::new(),
1716+ address: String::new(),
1717+ description: None,
1718+ topics: vec![],
1719+ archive_url: None,
1720+ },
1721+ 0,
1722+ ),
1723+ is_description_html_safe: false,
1724+ };
1725+
1726+ let mut list_owners = vec![ListOwner {
1727+ pk: 0,
1728+ list: 0,
1729+ address: "admin@example.com".to_string(),
1730+ name: None,
1731+ }];
1732+ let administrators = vec!["admin@example.com".to_string()];
1733+ list.set_safety(&list_owners, &administrators);
1734+ assert!(list.is_description_html_safe);
1735+ list.set_safety::<ListOwner>(&[], &[]);
1736+ assert!(list.is_description_html_safe);
1737+ list.is_description_html_safe = false;
1738+ list_owners[0].address = "user@example.com".to_string();
1739+ list.set_safety(&list_owners, &administrators);
1740+ assert!(!list.is_description_html_safe);
1741+ }
1742+ }
1743 diff --git a/mailpot-web/src/minijinja_utils/objects.rs b/mailpot-web/src/minijinja_utils/objects.rs
1744new file mode 100644
1745index 0000000..75aaaac
1746--- /dev/null
1747+++ b/mailpot-web/src/minijinja_utils/objects.rs
1748 @@ -0,0 +1,161 @@
1749+ /*
1750+ * This file is part of mailpot
1751+ *
1752+ * Copyright 2020 - Manos Pitsidianakis
1753+ *
1754+ * This program is free software: you can redistribute it and/or modify
1755+ * it under the terms of the GNU Affero General Public License as
1756+ * published by the Free Software Foundation, either version 3 of the
1757+ * License, or (at your option) any later version.
1758+ *
1759+ * This program is distributed in the hope that it will be useful,
1760+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1761+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1762+ * GNU Affero General Public License for more details.
1763+ *
1764+ * You should have received a copy of the GNU Affero General Public License
1765+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
1766+ */
1767+
1768+ //! Utils for templates with the [`minijinja`] crate.
1769+
1770+ use mailpot::models::ListOwner;
1771+
1772+ use super::*;
1773+
1774+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
1775+ pub struct MailingList {
1776+ pub pk: i64,
1777+ pub name: String,
1778+ pub id: String,
1779+ pub address: String,
1780+ pub description: Option<String>,
1781+ pub topics: Vec<String>,
1782+ #[serde(serialize_with = "super::utils::to_safe_string_opt")]
1783+ pub archive_url: Option<String>,
1784+ pub inner: DbVal<mailpot::models::MailingList>,
1785+ #[serde(default)]
1786+ pub is_description_html_safe: bool,
1787+ }
1788+
1789+ impl MailingList {
1790+ /// Set whether it's safe to not escape the list's description field.
1791+ ///
1792+ /// If anyone can display arbitrary html in the server, that's bad.
1793+ ///
1794+ /// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
1795+ /// `ListOwner` slices.
1796+ pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
1797+ &mut self,
1798+ owners: &[O],
1799+ administrators: &[String],
1800+ ) {
1801+ if owners.is_empty() || administrators.is_empty() {
1802+ return;
1803+ }
1804+ self.is_description_html_safe = owners
1805+ .iter()
1806+ .any(|o| administrators.contains(&o.borrow().address));
1807+ }
1808+ }
1809+
1810+ impl From<DbVal<mailpot::models::MailingList>> for MailingList {
1811+ fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
1812+ let DbVal(
1813+ mailpot::models::MailingList {
1814+ pk,
1815+ name,
1816+ id,
1817+ address,
1818+ description,
1819+ topics,
1820+ archive_url,
1821+ },
1822+ _,
1823+ ) = val.clone();
1824+
1825+ Self {
1826+ pk,
1827+ name,
1828+ id,
1829+ address,
1830+ description,
1831+ topics,
1832+ archive_url,
1833+ inner: val,
1834+ is_description_html_safe: false,
1835+ }
1836+ }
1837+ }
1838+
1839+ impl std::fmt::Display for MailingList {
1840+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
1841+ self.id.fmt(fmt)
1842+ }
1843+ }
1844+
1845+ impl Object for MailingList {
1846+ fn kind(&self) -> minijinja::value::ObjectKind {
1847+ minijinja::value::ObjectKind::Struct(self)
1848+ }
1849+
1850+ fn call_method(
1851+ &self,
1852+ _state: &minijinja::State,
1853+ name: &str,
1854+ _args: &[Value],
1855+ ) -> std::result::Result<Value, Error> {
1856+ match name {
1857+ "subscription_mailto" => {
1858+ Ok(Value::from_serializable(&self.inner.subscription_mailto()))
1859+ }
1860+ "unsubscription_mailto" => Ok(Value::from_serializable(
1861+ &self.inner.unsubscription_mailto(),
1862+ )),
1863+ "topics" => topics_common(&self.topics),
1864+ _ => Err(Error::new(
1865+ minijinja::ErrorKind::UnknownMethod,
1866+ format!("object has no method named {name}"),
1867+ )),
1868+ }
1869+ }
1870+ }
1871+
1872+ impl minijinja::value::StructObject for MailingList {
1873+ fn get_field(&self, name: &str) -> Option<Value> {
1874+ match name {
1875+ "pk" => Some(Value::from_serializable(&self.pk)),
1876+ "name" => Some(Value::from_serializable(&self.name)),
1877+ "id" => Some(Value::from_serializable(&self.id)),
1878+ "address" => Some(Value::from_serializable(&self.address)),
1879+ "description" if self.is_description_html_safe => {
1880+ self.description.as_ref().map_or_else(
1881+ || Some(Value::from_serializable(&self.description)),
1882+ |d| Some(Value::from_safe_string(d.clone())),
1883+ )
1884+ }
1885+ "description" => Some(Value::from_serializable(&self.description)),
1886+ "topics" => Some(Value::from_serializable(&self.topics)),
1887+ "archive_url" => Some(Value::from_serializable(&self.archive_url)),
1888+ "is_description_html_safe" => {
1889+ Some(Value::from_serializable(&self.is_description_html_safe))
1890+ }
1891+ _ => None,
1892+ }
1893+ }
1894+
1895+ fn static_fields(&self) -> Option<&'static [&'static str]> {
1896+ Some(
1897+ &[
1898+ "pk",
1899+ "name",
1900+ "id",
1901+ "address",
1902+ "description",
1903+ "topics",
1904+ "archive_url",
1905+ "is_description_html_safe",
1906+ ][..],
1907+ )
1908+ }
1909+ }
1910 diff --git a/mailpot-web/src/settings.rs b/mailpot-web/src/settings.rs
1911index 13a6736..5d03a50 100644
1912--- a/mailpot-web/src/settings.rs
1913+++ b/mailpot-web/src/settings.rs
1914 @@ -231,7 +231,7 @@ pub async fn settings_POST(
1915 level: Level::Success,
1916 })?;
1917 let mut user = user.clone();
1918- user.name = new.clone();
1919+ user.name.clone_from(&new);
1920 state.insert_user(acc.pk(), user).await;
1921 }
1922 }
1923 diff --git a/mailpot-web/src/templates/css.html b/mailpot-web/src/templates/css.html
1924index f644210..7b9fe32 100644
1925--- a/mailpot-web/src/templates/css.html
1926+++ b/mailpot-web/src/templates/css.html
1927 @@ -671,7 +671,7 @@
1928 }
1929
1930 table.headers tr>th {
1931- text-align: left;
1932+ text-align: right;
1933 color: var(--text-faded);
1934 }
1935
1936 @@ -711,6 +711,7 @@
1937
1938 td.message-id,
1939 span.message-id{
1940+ user-select: all;
1941 color: var(--text-faded);
1942 }
1943 .message-id>a {
1944 diff --git a/mailpot-web/src/templates/lists/entry.html b/mailpot-web/src/templates/lists/entry.html
1945index 6920257..f73a078 100644
1946--- a/mailpot-web/src/templates/lists/entry.html
1947+++ b/mailpot-web/src/templates/lists/entry.html
1948 @@ -10,12 +10,24 @@
1949 <td><bdi>{{ post.address }}</bdi></td>
1950 </tr>
1951 <tr>
1952+ <th scope="row">To:</th>
1953+ <td><bdi>{% if post.to %}{{ post.to }}{% else %}{{ list.address }}{% endif %}</bdi></td>
1954+ </tr>
1955+ {% if post.cc %}
1956+ <tr>
1957+ <th scope="row">Cc:</th>
1958+ <td><bdi>{{ post.cc }}</bdi></td>
1959+ </tr>
1960+ {% endif %}
1961+ <tr>
1962 <th scope="row">Date:</th>
1963 <td class="faded">{{ post.datetime }}</td>
1964 </tr>
1965 <tr>
1966 <th scope="row">Message-ID:</th>
1967- <td class="faded message-id"><a href="{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
1968+ <td class="faded"><span class="message-id">{{ strip_carets(post.message_id) }}</span>
1969+ <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>
1970+ </td>
1971 </tr>
1972 {% if in_reply_to %}
1973 <tr>
1974 @@ -29,11 +41,11 @@
1975 <td>{% for r in references %}<span class="faded message-id"><a href="{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
1976 </tr>
1977 {% endif %}
1978- <tr>
1979- <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>
1980- </tr>
1981 </table>
1982 <div class="post-body">
1983 <pre {% if odd %}style="--background-secondary: var(--background-critical);" {% endif %}title="E-mail text content">{{ body|trim }}</pre>
1984 </div>
1985+ <div class="post-reply-link">{# [ref:TODO] also reply to list email. #}
1986+ <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>
1987+ </div>
1988 </div>
1989 diff --git a/mailpot-web/src/typed_paths.rs b/mailpot-web/src/typed_paths.rs
1990index 6e0b3de..06a3d11 100644
1991--- a/mailpot-web/src/typed_paths.rs
1992+++ b/mailpot-web/src/typed_paths.rs
1993 @@ -95,6 +95,10 @@ pub struct ListPostRawPath(pub ListPathIdentifier, pub String);
1994 pub struct ListPostEmlPath(pub ListPathIdentifier, pub String);
1995
1996 #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
1997+ #[typed_path("/list/:id/posts/:msgid/mbox/")]
1998+ pub struct ListPostMboxPath(pub ListPathIdentifier, pub String);
1999+
2000+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
2001 #[typed_path("/list/:id/edit/")]
2002 pub struct ListEditPath(pub ListPathIdentifier);
2003
2004 @@ -209,6 +213,7 @@ macro_rules! list_post_impl {
2005 list_post_impl!(list_post_path, ListPostPath);
2006 list_post_impl!(post_raw_path, ListPostRawPath);
2007 list_post_impl!(post_eml_path, ListPostEmlPath);
2008+ list_post_impl!(post_mbox_path, ListPostMboxPath);
2009
2010 pub mod tsr {
2011 use std::{borrow::Cow, convert::Infallible};
2012 diff --git a/mailpot/src/postfix.rs b/mailpot/src/postfix.rs
2013index 519f803..9c9e885 100644
2014--- a/mailpot/src/postfix.rs
2015+++ b/mailpot/src/postfix.rs
2016 @@ -412,6 +412,7 @@ fn test_postfix_generation() -> Result<()> {
2017 let mut conf = OpenOptions::new()
2018 .write(true)
2019 .create(true)
2020+ .truncate(true)
2021 .open(&config_path)?;
2022 conf.write_all(config.to_toml().as_bytes())?;
2023 conf.flush()?;
2024 @@ -575,7 +576,11 @@ mailman unix - n n - - pipe
2025
2026 let path = tmp_dir.path().join("master.cf");
2027 {
2028- let mut mastercf = OpenOptions::new().write(true).create(true).open(&path)?;
2029+ let mut mastercf = OpenOptions::new()
2030+ .write(true)
2031+ .create(true)
2032+ .truncate(true)
2033+ .open(&path)?;
2034 mastercf.write_all(master_edit_value.as_bytes())?;
2035 mastercf.flush()?;
2036 }