Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: 393446ea61b15bd41d649db920f7cea55607a1f2
Timestamp: Tue, 13 Feb 2024 07:48:54 +0000 (7 months ago)

+24245 -24244 +/-269 browse
Rename workspace dirs to their actual crate names
Rename workspace dirs to their actual crate names

Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
1diff --git a/Cargo.toml b/Cargo.toml
2index ebf396f..62e3889 100644
3--- a/Cargo.toml
4+++ b/Cargo.toml
5 @@ -1,12 +1,13 @@
6 [workspace]
7 resolver = "2"
8+
9 members = [
10- "archive-http",
11- "cli",
12- "core",
13+ "mailpot",
14+ "mailpot-archives",
15+ "mailpot-cli",
16+ "mailpot-http",
17 "mailpot-tests",
18- "rest-http",
19- "web",
20+ "mailpot-web",
21 ]
22
23 [profile.release]
24 diff --git a/Makefile b/Makefile
25index 4c2dc56..7888bba 100644
26--- a/Makefile
27+++ b/Makefile
28 @@ -6,7 +6,7 @@ DJHTMLBIN = djhtml
29 BLACKBIN = black
30 PRINTF = /usr/bin/printf
31
32- HTML_FILES := $(shell find web/src/templates -type f -print0 | tr '\0' ' ')
33+ HTML_FILES := $(shell find mailpot-web/src/templates -type f -print0 | tr '\0' ' ')
34 PY_FILES := $(shell find . -type f -name '*.py' -print0 | tr '\0' ' ')
35
36 .PHONY: check
37 diff --git a/archive-http/Cargo.toml b/archive-http/Cargo.toml
38deleted file mode 100644
39index b7ec33e..0000000
40--- a/archive-http/Cargo.toml
41+++ /dev/null
42 @@ -1,25 +0,0 @@
43- [package]
44- name = "mailpot-archives"
45- version = "0.1.1"
46- authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
47- edition = "2021"
48- license = "LICENSE"
49- readme = "README.md"
50- description = "mailing list manager"
51- repository = "https://github.com/meli/mailpot"
52- keywords = ["mail", "mailing-lists"]
53- categories = ["email"]
54- default-run = "mpot-archives"
55-
56- [[bin]]
57- name = "mpot-archives"
58- path = "src/main.rs"
59-
60- [dependencies]
61- chrono = { version = "^0.4" }
62- lazy_static = "^1.4"
63- mailpot = { version = "^0.1", path = "../core" }
64- minijinja = { version = "0.31.0", features = ["source", ] }
65- percent-encoding = { version = "^2.1", optional = true }
66- serde = { version = "^1", features = ["derive", ] }
67- serde_json = "^1"
68 diff --git a/archive-http/README.md b/archive-http/README.md
69deleted file mode 100644
70index 623e387..0000000
71--- a/archive-http/README.md
72+++ /dev/null
73 @@ -1,12 +0,0 @@
74- # mailpot REST http server
75-
76- ```shell
77- cargo run --bin mpot-archives
78- ```
79-
80- ## generate static files
81-
82- ```shell
83- # mpot-gen CONF_FILE OUTPUT_DIR OPTIONAL_ROOT_URL_PREFIX
84- cargo run --bin mpot-gen -- ../conf.toml ./out/ "/mailpot"
85- ```
86 diff --git a/archive-http/rustfmt.toml b/archive-http/rustfmt.toml
87deleted file mode 120000
88index 39f97b0..0000000
89--- a/archive-http/rustfmt.toml
90+++ /dev/null
91 @@ -1 +0,0 @@
92- ../rustfmt.toml
93\ No newline at end of file
94 diff --git a/archive-http/src/cal.rs b/archive-http/src/cal.rs
95deleted file mode 100644
96index 3725d8a..0000000
97--- a/archive-http/src/cal.rs
98+++ /dev/null
99 @@ -1,244 +0,0 @@
100- // MIT License
101- //
102- // Copyright (c) 2021 sadnessOjisan
103- //
104- // Permission is hereby granted, free of charge, to any person obtaining a copy
105- // of this software and associated documentation files (the "Software"), to deal
106- // in the Software without restriction, including without limitation the rights
107- // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
108- // copies of the Software, and to permit persons to whom the Software is
109- // furnished to do so, subject to the following conditions:
110- //
111- // The above copyright notice and this permission notice shall be included in
112- // all copies or substantial portions of the Software.
113- //
114- // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
115- // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
116- // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
117- // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
118- // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
119- // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
120- // SOFTWARE.
121-
122- use chrono::*;
123-
124- #[allow(dead_code)]
125- /// Generate a calendar view of the given date's month.
126- ///
127- /// Each vector element is an array of seven numbers representing weeks
128- /// (starting on Sundays), and each value is the numeric date.
129- /// A value of zero means a date that not exists in the current month.
130- ///
131- /// # Examples
132- /// ```
133- /// use chrono::*;
134- /// use mailpot_archives::cal::calendarize;
135- ///
136- /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
137- /// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
138- /// println!("{:?}", calendarize(date));
139- /// // [0, 0, 0, 0, 0, 1, 2],
140- /// // [3, 4, 5, 6, 7, 8, 9],
141- /// // [10, 11, 12, 13, 14, 15, 16],
142- /// // [17, 18, 19, 20, 21, 22, 23],
143- /// // [24, 25, 26, 27, 28, 29, 30],
144- /// // [31, 0, 0, 0, 0, 0, 0]
145- /// ```
146- pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
147- calendarize_with_offset(date, 0)
148- }
149-
150- /// Generate a calendar view of the given date's month and offset.
151- ///
152- /// Each vector element is an array of seven numbers representing weeks
153- /// (starting on Sundays), and each value is the numeric date.
154- /// A value of zero means a date that not exists in the current month.
155- ///
156- /// Offset means the number of days from sunday.
157- /// For example, 1 means monday, 6 means saturday.
158- ///
159- /// # Examples
160- /// ```
161- /// use chrono::*;
162- /// use mailpot_archives::cal::calendarize_with_offset;
163- ///
164- /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
165- /// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
166- /// println!("{:?}", calendarize_with_offset(date, 1));
167- /// // [0, 0, 0, 0, 1, 2, 3],
168- /// // [4, 5, 6, 7, 8, 9, 10],
169- /// // [11, 12, 13, 14, 15, 16, 17],
170- /// // [18, 19, 20, 21, 22, 23, 24],
171- /// // [25, 26, 27, 28, 29, 30, 0],
172- /// ```
173- pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
174- let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
175- let year = date.year();
176- let month = date.month();
177- let num_days_from_sunday = NaiveDate::from_ymd_opt(year, month, 1)
178- .unwrap()
179- .weekday()
180- .num_days_from_sunday();
181- let mut first_date_day;
182- if num_days_from_sunday < offset {
183- first_date_day = num_days_from_sunday + (7 - offset);
184- } else {
185- first_date_day = num_days_from_sunday - offset;
186- }
187- let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
188- .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
189- .pred_opt()
190- .unwrap()
191- .day();
192-
193- let mut date: u32 = 0;
194- while date < end_date {
195- let mut week: [u32; 7] = [0; 7];
196- for day in first_date_day..7 {
197- date += 1;
198- week[day as usize] = date;
199- if date >= end_date {
200- break;
201- }
202- }
203- first_date_day = 0;
204-
205- monthly_calendar.push(week);
206- }
207-
208- monthly_calendar
209- }
210-
211- #[test]
212- fn january() {
213- let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
214- let actual = calendarize(date);
215- assert_eq!(
216- vec![
217- [0, 0, 0, 0, 0, 1, 2],
218- [3, 4, 5, 6, 7, 8, 9],
219- [10, 11, 12, 13, 14, 15, 16],
220- [17, 18, 19, 20, 21, 22, 23],
221- [24, 25, 26, 27, 28, 29, 30],
222- [31, 0, 0, 0, 0, 0, 0]
223- ],
224- actual
225- );
226- }
227-
228- #[test]
229- // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
230- fn with_offset_from_sunday() {
231- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
232- let actual = calendarize_with_offset(date, 0);
233- assert_eq!(
234- vec![
235- [0, 0, 0, 0, 0, 1, 2],
236- [3, 4, 5, 6, 7, 8, 9],
237- [10, 11, 12, 13, 14, 15, 16],
238- [17, 18, 19, 20, 21, 22, 23],
239- [24, 25, 26, 27, 28, 29, 30],
240- ],
241- actual
242- );
243- }
244-
245- #[test]
246- // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
247- fn with_offset_from_monday() {
248- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
249- let actual = calendarize_with_offset(date, 1);
250- assert_eq!(
251- vec![
252- [0, 0, 0, 0, 1, 2, 3],
253- [4, 5, 6, 7, 8, 9, 10],
254- [11, 12, 13, 14, 15, 16, 17],
255- [18, 19, 20, 21, 22, 23, 24],
256- [25, 26, 27, 28, 29, 30, 0],
257- ],
258- actual
259- );
260- }
261-
262- #[test]
263- // Week = [Sat, Sun, Mon, Tue, Wed, Thu, Fri]
264- fn with_offset_from_saturday() {
265- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
266- let actual = calendarize_with_offset(date, 6);
267- assert_eq!(
268- vec![
269- [0, 0, 0, 0, 0, 0, 1],
270- [2, 3, 4, 5, 6, 7, 8],
271- [9, 10, 11, 12, 13, 14, 15],
272- [16, 17, 18, 19, 20, 21, 22],
273- [23, 24, 25, 26, 27, 28, 29],
274- [30, 0, 0, 0, 0, 0, 0]
275- ],
276- actual
277- );
278- }
279-
280- #[test]
281- // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
282- fn with_offset_from_sunday_with7() {
283- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
284- let actual = calendarize_with_offset(date, 7);
285- assert_eq!(
286- vec![
287- [0, 0, 0, 0, 0, 1, 2],
288- [3, 4, 5, 6, 7, 8, 9],
289- [10, 11, 12, 13, 14, 15, 16],
290- [17, 18, 19, 20, 21, 22, 23],
291- [24, 25, 26, 27, 28, 29, 30],
292- ],
293- actual
294- );
295- }
296-
297- #[test]
298- fn april() {
299- let date = NaiveDate::parse_from_str("2021-04-02", "%Y-%m-%d").unwrap();
300- let actual = calendarize(date);
301- assert_eq!(
302- vec![
303- [0, 0, 0, 0, 1, 2, 3],
304- [4, 5, 6, 7, 8, 9, 10],
305- [11, 12, 13, 14, 15, 16, 17],
306- [18, 19, 20, 21, 22, 23, 24],
307- [25, 26, 27, 28, 29, 30, 0]
308- ],
309- actual
310- );
311- }
312-
313- #[test]
314- fn uruudoshi() {
315- let date = NaiveDate::parse_from_str("2020-02-02", "%Y-%m-%d").unwrap();
316- let actual = calendarize(date);
317- assert_eq!(
318- vec![
319- [0, 0, 0, 0, 0, 0, 1],
320- [2, 3, 4, 5, 6, 7, 8],
321- [9, 10, 11, 12, 13, 14, 15],
322- [16, 17, 18, 19, 20, 21, 22],
323- [23, 24, 25, 26, 27, 28, 29]
324- ],
325- actual
326- );
327- }
328-
329- #[test]
330- fn uruwanaidoshi() {
331- let date = NaiveDate::parse_from_str("2021-02-02", "%Y-%m-%d").unwrap();
332- let actual = calendarize(date);
333- assert_eq!(
334- vec![
335- [0, 1, 2, 3, 4, 5, 6],
336- [7, 8, 9, 10, 11, 12, 13],
337- [14, 15, 16, 17, 18, 19, 20],
338- [21, 22, 23, 24, 25, 26, 27],
339- [28, 0, 0, 0, 0, 0, 0]
340- ],
341- actual
342- );
343- }
344 diff --git a/archive-http/src/gen.rs b/archive-http/src/gen.rs
345deleted file mode 100644
346index 9f9025a..0000000
347--- a/archive-http/src/gen.rs
348+++ /dev/null
349 @@ -1,259 +0,0 @@
350- /*
351- * This file is part of mailpot
352- *
353- * Copyright 2020 - Manos Pitsidianakis
354- *
355- * This program is free software: you can redistribute it and/or modify
356- * it under the terms of the GNU Affero General Public License as
357- * published by the Free Software Foundation, either version 3 of the
358- * License, or (at your option) any later version.
359- *
360- * This program is distributed in the hope that it will be useful,
361- * but WITHOUT ANY WARRANTY; without even the implied warranty of
362- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
363- * GNU Affero General Public License for more details.
364- *
365- * You should have received a copy of the GNU Affero General Public License
366- * along with this program. If not, see <https://www.gnu.org/licenses/>.
367- */
368-
369- use std::{fs::OpenOptions, io::Write};
370-
371- use mailpot::*;
372- use mailpot_archives::utils::*;
373- use minijinja::value::Value;
374-
375- fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
376- let args = std::env::args().collect::<Vec<_>>();
377- let Some(config_path) = args
378- .get(1) else {
379- return Err("Expected configuration file path as first argument.".into());
380- };
381- let Some(output_path) = args
382- .get(2) else {
383- return Err("Expected output dir path as second argument.".into());
384- };
385- let root_url_prefix = args.get(3).cloned().unwrap_or_default();
386-
387- let output_path = std::path::Path::new(&output_path);
388- if output_path.exists() && !output_path.is_dir() {
389- return Err("Output path is not a directory.".into());
390- }
391-
392- std::fs::create_dir_all(&output_path.join("lists"))?;
393- std::fs::create_dir_all(&output_path.join("list"))?;
394- let conf = Configuration::from_file(config_path)
395- .map_err(|err| format!("Could not load config {config_path}: {err}"))?;
396-
397- let db = Connection::open_db(conf).map_err(|err| format!("Couldn't open db: {err}"))?;
398- let lists_values = db.lists()?;
399- {
400- //index.html
401-
402- let lists = lists_values
403- .iter()
404- .map(|list| {
405- let months = db.months(list.pk).unwrap();
406- let posts = db.list_posts(list.pk, None).unwrap();
407- minijinja::context! {
408- title => &list.name,
409- posts => &posts,
410- months => &months,
411- body => &list.description.as_deref().unwrap_or_default(),
412- root_prefix => &root_url_prefix,
413- list => Value::from_object(MailingList::from(list.clone())),
414- }
415- })
416- .collect::<Vec<_>>();
417- let mut file = OpenOptions::new()
418- .write(true)
419- .create(true)
420- .truncate(true)
421- .open(&output_path.join("index.html"))?;
422- let crumbs = vec![Crumb {
423- label: "Lists".into(),
424- url: format!("{root_url_prefix}/").into(),
425- }];
426-
427- let context = minijinja::context! {
428- title => "mailing list archive",
429- description => "",
430- lists => &lists,
431- root_prefix => &root_url_prefix,
432- crumbs => crumbs,
433- };
434- file.write_all(
435- TEMPLATES
436- .get_template("lists.html")?
437- .render(context)?
438- .as_bytes(),
439- )?;
440- }
441-
442- let mut lists_path = output_path.to_path_buf();
443-
444- for list in &lists_values {
445- lists_path.push("lists");
446- lists_path.push(list.pk.to_string());
447- std::fs::create_dir_all(&lists_path)?;
448- lists_path.push("index.html");
449-
450- let list = db.list(list.pk)?.unwrap();
451- let post_policy = db.list_post_policy(list.pk)?;
452- let months = db.months(list.pk)?;
453- let posts = db.list_posts(list.pk, None)?;
454- let mut hist = months
455- .iter()
456- .map(|m| (m.to_string(), [0usize; 31]))
457- .collect::<std::collections::HashMap<String, [usize; 31]>>();
458- let posts_ctx = posts
459- .iter()
460- .map(|post| {
461- //2019-07-14T14:21:02
462- if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
463- hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
464- }
465- let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
466- .expect("Could not parse mail");
467- let mut msg_id = &post.message_id[1..];
468- msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
469- let subject = envelope.subject();
470- let mut subject_ref = subject.trim();
471- if subject_ref.starts_with('[')
472- && subject_ref[1..].starts_with(&list.id)
473- && subject_ref[1 + list.id.len()..].starts_with(']')
474- {
475- subject_ref = subject_ref[2 + list.id.len()..].trim();
476- }
477- minijinja::context! {
478- pk => post.pk,
479- list => post.list,
480- subject => subject_ref,
481- address=> post.address,
482- message_id => msg_id,
483- message => post.message,
484- timestamp => post.timestamp,
485- datetime => post.datetime,
486- root_prefix => &root_url_prefix,
487- }
488- })
489- .collect::<Vec<_>>();
490- let crumbs = vec![
491- Crumb {
492- label: "Lists".into(),
493- url: format!("{root_url_prefix}/").into(),
494- },
495- Crumb {
496- label: list.name.clone().into(),
497- url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
498- },
499- ];
500- let context = minijinja::context! {
501- title=> &list.name,
502- description=> &list.description,
503- post_policy=> &post_policy,
504- preamble => true,
505- months=> &months,
506- hists => &hist,
507- posts=> posts_ctx,
508- body=>&list.description.clone().unwrap_or_default(),
509- root_prefix => &root_url_prefix,
510- list => Value::from_object(MailingList::from(list.clone())),
511- crumbs => crumbs,
512- };
513- let mut file = OpenOptions::new()
514- .read(true)
515- .write(true)
516- .create(true)
517- .truncate(true)
518- .open(&lists_path)
519- .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
520- file.write_all(
521- TEMPLATES
522- .get_template("list.html")?
523- .render(context)?
524- .as_bytes(),
525- )?;
526- lists_path.pop();
527- lists_path.pop();
528- lists_path.pop();
529- lists_path.push("list");
530- lists_path.push(list.pk.to_string());
531- std::fs::create_dir_all(&lists_path)?;
532-
533- for post in posts {
534- let mut msg_id = &post.message_id[1..];
535- msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
536- lists_path.push(format!("{msg_id}.html"));
537- let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
538- .map_err(|err| format!("Could not parse mail {}: {err}", post.message_id))?;
539- let body = envelope.body_bytes(post.message.as_slice());
540- let body_text = body.text();
541- let subject = envelope.subject();
542- let mut subject_ref = subject.trim();
543- if subject_ref.starts_with('[')
544- && subject_ref[1..].starts_with(&list.id)
545- && subject_ref[1 + list.id.len()..].starts_with(']')
546- {
547- subject_ref = subject_ref[2 + list.id.len()..].trim();
548- }
549- let mut message_id = &post.message_id[1..];
550- message_id = &message_id[..message_id.len().saturating_sub(1)];
551- let crumbs = vec![
552- Crumb {
553- label: "Lists".into(),
554- url: format!("{root_url_prefix}/").into(),
555- },
556- Crumb {
557- label: list.name.clone().into(),
558- url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
559- },
560- Crumb {
561- label: subject_ref.to_string().into(),
562- url: format!("{root_url_prefix}/lists/{}/{message_id}.html/", list.pk).into(),
563- },
564- ];
565- let context = minijinja::context! {
566- title => &list.name,
567- list => &list,
568- post => &post,
569- posts => &posts_ctx,
570- body => &body_text,
571- from => &envelope.field_from_to_string(),
572- date => &envelope.date_as_str(),
573- to => &envelope.field_to_to_string(),
574- subject => &envelope.subject(),
575- trimmed_subject => subject_ref,
576- in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
577- references => &envelope .references() .into_iter() .map(|m| m.to_string().as_str().strip_carets().to_string()) .collect::<Vec<String>>(),
578- root_prefix => &root_url_prefix,
579- crumbs => crumbs,
580- };
581- let mut file = OpenOptions::new()
582- .read(true)
583- .write(true)
584- .create(true)
585- .truncate(true)
586- .open(&lists_path)
587- .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
588- file.write_all(
589- TEMPLATES
590- .get_template("post.html")?
591- .render(context)?
592- .as_bytes(),
593- )?;
594- lists_path.pop();
595- }
596- lists_path.pop();
597- lists_path.pop();
598- }
599- Ok(())
600- }
601-
602- fn main() -> std::result::Result<(), i64> {
603- if let Err(err) = run_app() {
604- eprintln!("{err}");
605- return Err(-1);
606- }
607- Ok(())
608- }
609 diff --git a/archive-http/src/lib.rs b/archive-http/src/lib.rs
610deleted file mode 100644
611index bf855fd..0000000
612--- a/archive-http/src/lib.rs
613+++ /dev/null
614 @@ -1,21 +0,0 @@
615- /*
616- * This file is part of mailpot
617- *
618- * Copyright 2020 - Manos Pitsidianakis
619- *
620- * This program is free software: you can redistribute it and/or modify
621- * it under the terms of the GNU Affero General Public License as
622- * published by the Free Software Foundation, either version 3 of the
623- * License, or (at your option) any later version.
624- *
625- * This program is distributed in the hope that it will be useful,
626- * but WITHOUT ANY WARRANTY; without even the implied warranty of
627- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
628- * GNU Affero General Public License for more details.
629- *
630- * You should have received a copy of the GNU Affero General Public License
631- * along with this program. If not, see <https://www.gnu.org/licenses/>.
632- */
633-
634- pub mod cal;
635- pub mod utils;
636 diff --git a/archive-http/src/main.rs b/archive-http/src/main.rs
637deleted file mode 100644
638index e6ae3cc..0000000
639--- a/archive-http/src/main.rs
640+++ /dev/null
641 @@ -1,257 +0,0 @@
642- /*
643- * This file is part of mailpot
644- *
645- * Copyright 2020 - Manos Pitsidianakis
646- *
647- * This program is free software: you can redistribute it and/or modify
648- * it under the terms of the GNU Affero General Public License as
649- * published by the Free Software Foundation, either version 3 of the
650- * License, or (at your option) any later version.
651- *
652- * This program is distributed in the hope that it will be useful,
653- * but WITHOUT ANY WARRANTY; without even the implied warranty of
654- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
655- * GNU Affero General Public License for more details.
656- *
657- * You should have received a copy of the GNU Affero General Public License
658- * along with this program. If not, see <https://www.gnu.org/licenses/>.
659- */
660-
661- use std::{fs::OpenOptions, io::Write};
662-
663- use mailpot::*;
664- use mailpot_archives::utils::*;
665- use minijinja::value::Value;
666-
667- fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
668- let args = std::env::args().collect::<Vec<_>>();
669- let Some(config_path) = args.get(1) else {
670- return Err("Expected configuration file path as first argument.".into());
671- };
672- let Some(output_path) = args.get(2) else {
673- return Err("Expected output dir path as second argument.".into());
674- };
675- let root_url_prefix = args.get(3).cloned().unwrap_or_default();
676-
677- let output_path = std::path::Path::new(&output_path);
678- if output_path.exists() && !output_path.is_dir() {
679- return Err("Output path is not a directory.".into());
680- }
681-
682- std::fs::create_dir_all(output_path.join("lists"))?;
683- std::fs::create_dir_all(output_path.join("list"))?;
684- let conf = Configuration::from_file(config_path)
685- .map_err(|err| format!("Could not load config {config_path}: {err}"))?;
686-
687- let db = Connection::open_db(conf).map_err(|err| format!("Couldn't open db: {err}"))?;
688- let lists_values = db.lists()?;
689- {
690- //index.html
691-
692- let lists = lists_values
693- .iter()
694- .map(|list| {
695- let months = db.months(list.pk).unwrap();
696- let posts = db.list_posts(list.pk, None).unwrap();
697- minijinja::context! {
698- title => &list.name,
699- posts => &posts,
700- months => &months,
701- body => &list.description.as_deref().unwrap_or_default(),
702- root_prefix => &root_url_prefix,
703- list => Value::from_object(MailingList::from(list.clone())),
704- }
705- })
706- .collect::<Vec<_>>();
707- let mut file = OpenOptions::new()
708- .write(true)
709- .create(true)
710- .truncate(true)
711- .open(output_path.join("index.html"))?;
712- let crumbs = vec![Crumb {
713- label: "Lists".into(),
714- url: format!("{root_url_prefix}/").into(),
715- }];
716-
717- let context = minijinja::context! {
718- title => "mailing list archive",
719- description => "",
720- lists => &lists,
721- root_prefix => &root_url_prefix,
722- crumbs => crumbs,
723- };
724- file.write_all(
725- TEMPLATES
726- .get_template("lists.html")?
727- .render(context)?
728- .as_bytes(),
729- )?;
730- }
731-
732- let mut lists_path = output_path.to_path_buf();
733-
734- for list in &lists_values {
735- lists_path.push("lists");
736- lists_path.push(list.pk.to_string());
737- std::fs::create_dir_all(&lists_path)?;
738- lists_path.push("index.html");
739-
740- let list = db.list(list.pk)?.unwrap();
741- let post_policy = db.list_post_policy(list.pk)?;
742- let months = db.months(list.pk)?;
743- let posts = db.list_posts(list.pk, None)?;
744- let mut hist = months
745- .iter()
746- .map(|m| (m.to_string(), [0usize; 31]))
747- .collect::<std::collections::HashMap<String, [usize; 31]>>();
748- let posts_ctx = posts
749- .iter()
750- .map(|post| {
751- //2019-07-14T14:21:02
752- if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
753- hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
754- }
755- let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
756- .expect("Could not parse mail");
757- let mut msg_id = &post.message_id[1..];
758- msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
759- let subject = envelope.subject();
760- let mut subject_ref = subject.trim();
761- if subject_ref.starts_with('[')
762- && subject_ref[1..].starts_with(&list.id)
763- && subject_ref[1 + list.id.len()..].starts_with(']')
764- {
765- subject_ref = subject_ref[2 + list.id.len()..].trim();
766- }
767- minijinja::context! {
768- pk => post.pk,
769- list => post.list,
770- subject => subject_ref,
771- address=> post.address,
772- message_id => msg_id,
773- message => post.message,
774- timestamp => post.timestamp,
775- datetime => post.datetime,
776- root_prefix => &root_url_prefix,
777- }
778- })
779- .collect::<Vec<_>>();
780- let crumbs = vec![
781- Crumb {
782- label: "Lists".into(),
783- url: format!("{root_url_prefix}/").into(),
784- },
785- Crumb {
786- label: list.name.clone().into(),
787- url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
788- },
789- ];
790- let context = minijinja::context! {
791- title=> &list.name,
792- description=> &list.description,
793- post_policy=> &post_policy,
794- preamble => true,
795- months=> &months,
796- hists => &hist,
797- posts=> posts_ctx,
798- body=>&list.description.clone().unwrap_or_default(),
799- root_prefix => &root_url_prefix,
800- list => Value::from_object(MailingList::from(list.clone())),
801- crumbs => crumbs,
802- };
803- let mut file = OpenOptions::new()
804- .read(true)
805- .write(true)
806- .create(true)
807- .truncate(true)
808- .open(&lists_path)
809- .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
810- file.write_all(
811- TEMPLATES
812- .get_template("list.html")?
813- .render(context)?
814- .as_bytes(),
815- )?;
816- lists_path.pop();
817- lists_path.pop();
818- lists_path.pop();
819- lists_path.push("list");
820- lists_path.push(list.pk.to_string());
821- std::fs::create_dir_all(&lists_path)?;
822-
823- for post in posts {
824- let mut msg_id = &post.message_id[1..];
825- msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
826- lists_path.push(format!("{msg_id}.html"));
827- let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
828- .map_err(|err| format!("Could not parse mail {}: {err}", post.message_id))?;
829- let body = envelope.body_bytes(post.message.as_slice());
830- let body_text = body.text();
831- let subject = envelope.subject();
832- let mut subject_ref = subject.trim();
833- if subject_ref.starts_with('[')
834- && subject_ref[1..].starts_with(&list.id)
835- && subject_ref[1 + list.id.len()..].starts_with(']')
836- {
837- subject_ref = subject_ref[2 + list.id.len()..].trim();
838- }
839- let mut message_id = &post.message_id[1..];
840- message_id = &message_id[..message_id.len().saturating_sub(1)];
841- let crumbs = vec![
842- Crumb {
843- label: "Lists".into(),
844- url: format!("{root_url_prefix}/").into(),
845- },
846- Crumb {
847- label: list.name.clone().into(),
848- url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
849- },
850- Crumb {
851- label: subject_ref.to_string().into(),
852- url: format!("{root_url_prefix}/lists/{}/{message_id}.html/", list.pk).into(),
853- },
854- ];
855- let context = minijinja::context! {
856- title => &list.name,
857- list => &list,
858- post => &post,
859- posts => &posts_ctx,
860- body => &body_text,
861- from => &envelope.field_from_to_string(),
862- date => &envelope.date_as_str(),
863- to => &envelope.field_to_to_string(),
864- subject => &envelope.subject(),
865- trimmed_subject => subject_ref,
866- in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
867- references => &envelope .references() .into_iter() .map(|m| m.to_string().as_str().strip_carets().to_string()) .collect::<Vec<String>>(),
868- root_prefix => &root_url_prefix,
869- crumbs => crumbs,
870- };
871- let mut file = OpenOptions::new()
872- .read(true)
873- .write(true)
874- .create(true)
875- .truncate(true)
876- .open(&lists_path)
877- .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
878- file.write_all(
879- TEMPLATES
880- .get_template("post.html")?
881- .render(context)?
882- .as_bytes(),
883- )?;
884- lists_path.pop();
885- }
886- lists_path.pop();
887- lists_path.pop();
888- }
889- Ok(())
890- }
891-
892- fn main() -> std::result::Result<(), i64> {
893- if let Err(err) = run_app() {
894- eprintln!("{err}");
895- return Err(-1);
896- }
897- Ok(())
898- }
899 diff --git a/archive-http/src/templates/calendar.html b/archive-http/src/templates/calendar.html
900deleted file mode 100644
901index 22e4668..0000000
902--- a/archive-http/src/templates/calendar.html
903+++ /dev/null
904 @@ -1,43 +0,0 @@
905- {% macro cal(date, hists, root_prefix, pk) %}
906- {% set c=calendarize(date, hists) %}
907- {% if c.sum > 0 %}
908- <table>
909- <caption align="top">
910- <!--<a href="{{ root_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
911- <a href="#" style="color: GrayText;">
912- {{ c.month_name }} {{ c.year }}
913- </a>
914- </caption>
915- <thead>
916- <tr>
917- <th>M</th>
918- <th>Tu</th>
919- <th>W</th>
920- <th>Th</th>
921- <th>F</th>
922- <th>Sa</th>
923- <th>Su</th>
924- </tr>
925- </thead>
926- <tbody>
927- {% for week in c.weeks %}
928- <tr>
929- {% for day in week %}
930- {% if day == 0 %}
931- <td></td>
932- {% else %}
933- {% set num = c.hist[day-1] %}
934- {% if num > 0 %}
935- <td><ruby>{{ day }}<rt>({{ num }})</rt></ruby></td>
936- {% else %}
937- <td class="empty">{{ day }}</td>
938- {% endif %}
939- {% endif %}
940- {% endfor %}
941- </tr>
942- {% endfor %}
943- </tbody>
944- </table>
945- {% endif %}
946- {% endmacro %}
947- {% set alias = cal %}
948 diff --git a/archive-http/src/templates/css.html b/archive-http/src/templates/css.html
949deleted file mode 100644
950index 1f5d06b..0000000
951--- a/archive-http/src/templates/css.html
952+++ /dev/null
953 @@ -1,307 +0,0 @@
954- <style>
955- @charset "UTF-8";
956- * Use a more intuitive box-sizing model */
957- *, *::before, *::after {
958- box-sizing: border-box;
959- }
960-
961- /* Remove all margins & padding */
962- * {
963- margin: 0;
964- padding: 0;
965- }
966-
967- /* Only show focus outline when the user is tabbing (not when clicking) */
968- *:focus {
969- outline: none;
970- }
971-
972- *:focus-visible {
973- outline: 1px solid blue;
974- }
975-
976- /* Prevent mobile browsers increasing font-size */
977- html {
978- -moz-text-size-adjust: none;
979- -webkit-text-size-adjust: none;
980- text-size-adjust: none;
981- font-family:-apple-system,BlinkMacSystemFont,Arial,sans-serif;
982- line-height:1.15;
983- -webkit-text-size-adjust:100%;
984- overflow-y:scroll;
985- }
986-
987- /* Allow percentage-based heights */
988- /* Setting width: 100% isn't required because it is a default for block-level elements (html & body are block level) */
989- html, body {
990- height: 100%;
991- }
992-
993- body {
994- /* Prevent the rubber band effect when the user scrolls to the top or bottom of the page (WebKit only) */
995- overscroll-behavior: none;
996-
997- /* Prevent the browser from synthesizing missing typefaces */
998- font-synthesis: none;
999-
1000- color: black;
1001- /* UI controls color (example: range input) */
1002- accent-color: black;
1003-
1004- /* Because overscroll-behavior: none only works on WebKit, a background color is set that will show when overscroll occurs */
1005- background: white;
1006- margin:0;
1007- font-feature-settings:"onum" 1;
1008- text-rendering:optimizeLegibility;
1009- -webkit-font-smoothing:antialiased;
1010- -moz-osx-font-smoothing:grayscale;
1011- font-family:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif;
1012- font-size:1.125em
1013- }
1014-
1015- /* Remove unintuitive behaviour such as gaps around media elements. */
1016- img, picture, video, canvas, svg, iframe {
1017- display: block;
1018- }
1019-
1020- /* Avoid text overflow */
1021- h1, h2, h3, h4, h5, h6, p, strong {
1022- overflow-wrap: break-word;
1023- }
1024-
1025- a {
1026- text-decoration: none;
1027- }
1028-
1029- ul, ol {
1030- list-style: none;
1031- }
1032-
1033- input {
1034- border: none;
1035- }
1036-
1037- input, button, textarea, select {
1038- font: inherit;
1039- }
1040-
1041- /* Create a root stacking context (only when using frameworks like Next.js) */
1042- #__next {
1043- isolation: isolate;
1044- }
1045-
1046-
1047- body>main.layout {
1048- width: 100%;
1049- overflow-wrap: anywhere;
1050-
1051- display: grid;
1052- grid:
1053- "header header header" auto
1054- "leftside body rightside" 1fr
1055- "footer footer footer" auto
1056- / auto 1fr auto;
1057- gap: 8px;
1058- }
1059-
1060- main.layout>.header { grid-area: header; }
1061- main.layout>.leftside { grid-area: leftside; }
1062- main.layout>div.body { grid-area: body; }
1063- main.layout>.rightside { grid-area: rightside; }
1064- main.layout>footer {
1065- grid-area: footer;
1066- padding: 1rem 2rem;
1067- }
1068-
1069- main.layout>div.header>h1 {
1070- margin: 1rem;
1071- }
1072-
1073- main.layout>div.body h2 {
1074- margin: 1rem;
1075- }
1076-
1077- nav.breadcrumb ul:before {
1078- content: "≫";
1079- display: inline-block;
1080- margin-right: 0.6rem;
1081- }
1082-
1083- .breadcrumb a {
1084- padding: 0.4rem;
1085- margin: -0.4rem;
1086- font-size: larger;
1087- }
1088-
1089- .breadcrumb>ul>li:first-child a {
1090- padding-left: 0rem;
1091- }
1092-
1093- .breadcrumb {
1094- padding: 0rem 0.5rem;
1095- margin: 1rem;
1096- }
1097-
1098- .breadcrumb span[aria-current="page"] {
1099- color: GrayText;
1100- vertical-align: sub;
1101- padding: 0.4rem;
1102- margin-left: -0.4rem;
1103- }
1104-
1105- .breadcrumb ul {
1106- display: flex;
1107- flex-wrap: wrap;
1108- list-style: none;
1109- margin: 0;
1110- padding: 0;
1111- }
1112-
1113- .breadcrumb li:not(:last-child)::after {
1114- display: inline-block;
1115- margin: 0rem 0.25rem;
1116- content: "→";
1117- vertical-align: text-bottom;
1118- }
1119-
1120- div.preamble {
1121- border-left: 0.2rem solid GrayText;
1122- padding-left: 0.5rem;
1123- }
1124-
1125- div.calendar th {
1126- padding: 0.5rem;
1127- opacity: 0.7;
1128- }
1129-
1130- div.calendar tr,
1131- div.calendar th {
1132- text-align: right;
1133- font-variant-numeric: tabular-nums;
1134- font-family: monospace;
1135- }
1136-
1137- div.calendar table {
1138- display: inline-table;
1139- border-collapse: collapse;
1140- }
1141-
1142- div.calendar td {
1143- padding: 0.1rem 0.4rem;
1144- }
1145-
1146- div.calendar td.empty {
1147- color: GrayText;
1148- }
1149-
1150- div.calendar td:not(.empty) {
1151- font-weight: bold;
1152- }
1153-
1154- div.calendar td:not(:empty) {
1155- border: 1px solid black;
1156- }
1157-
1158- div.calendar td:empty {
1159- background: GrayText;
1160- opacity: 0.3;
1161- }
1162-
1163- div.calendar {
1164- display: flex;
1165- flex-wrap: wrap;
1166- flex-direction: row;
1167- gap: 1rem;
1168- align-items: baseline;
1169- }
1170-
1171- div.calendar caption {
1172- font-weight: bold;
1173- }
1174-
1175- div.posts {
1176- display: flex;
1177- flex-direction: column;
1178- gap: 1rem;
1179- }
1180-
1181- div.posts>div.entry {
1182- display: flex;
1183- flex-direction: column;
1184- gap: 0.5rem;
1185- }
1186-
1187- div.posts>div.entry>span.subject {
1188- font-size: larger;
1189- }
1190-
1191- div.posts>div.entry>span.metadata {
1192- color: GrayText;
1193- }
1194-
1195- div.posts>div.entry>span.metadata>span.from {
1196- margin-inline-end: 1rem;
1197- }
1198-
1199- table.headers tr>th {
1200- text-align: right;
1201- color: GrayText;
1202- }
1203- table.headers th[scope="row"] {
1204- padding-right: .5rem;
1205- }
1206- table.headers tr>th:after {
1207- content:':';
1208- display: inline-block;
1209- }
1210- div.post-body {
1211- margin: 1rem;
1212- }
1213- div.post-body>pre {
1214- max-width: 98vw;
1215- overflow-wrap: break-word;
1216- white-space: pre-line;
1217- }
1218- td.message-id,
1219- span.message-id{
1220- color: GrayText;
1221- }
1222- td.message-id:before,
1223- span.message-id:before{
1224- content:'<';
1225- display: inline-block;
1226- }
1227- td.message-id:after,
1228- span.message-id:after{
1229- content:'>';
1230- display: inline-block;
1231- }
1232- span.message-id + span.message-id:before{
1233- content:', <';
1234- display: inline-block;
1235- }
1236- td.faded,
1237- span.faded {
1238- color: GrayText;
1239- }
1240- td.faded:is(:focus, :hover, :focus-visible, :focus-within),
1241- span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
1242- color: revert;
1243- }
1244-
1245- ul.lists {
1246- padding: 1rem 2rem;
1247- }
1248-
1249- ul.lists li {
1250- list-style: disc;
1251- }
1252-
1253- ul.lists li + li {
1254- margin-top: 1rem;
1255- }
1256-
1257- hr {
1258- margin: 1rem 0rem;
1259- }
1260- </style>
1261 diff --git a/archive-http/src/templates/footer.html b/archive-http/src/templates/footer.html
1262deleted file mode 100644
1263index 048935f..0000000
1264--- a/archive-http/src/templates/footer.html
1265+++ /dev/null
1266 @@ -1,8 +0,0 @@
1267- <footer>
1268- <hr />
1269- <p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
1270- </footer>
1271- </main>
1272- </body>
1273- </html>
1274-
1275 diff --git a/archive-http/src/templates/header.html b/archive-http/src/templates/header.html
1276deleted file mode 100644
1277index d7c2c0c..0000000
1278--- a/archive-http/src/templates/header.html
1279+++ /dev/null
1280 @@ -1,17 +0,0 @@
1281- <!DOCTYPE html>
1282- <html lang="en">
1283- <head>
1284- <meta charset="utf-8">
1285- <title>{{ title }}</title>
1286- {% include "css.html" %}
1287- </head>
1288- <body>
1289- <main class="layout">
1290- <div class="header">
1291- <h1>{{ title }}</h1>
1292- {% if description %}
1293- <p class="description">{{ description }}</p>
1294- {% endif %}
1295- {% include "menu.html" %}
1296- <hr />
1297- </div>
1298 diff --git a/archive-http/src/templates/index.html b/archive-http/src/templates/index.html
1299deleted file mode 100644
1300index 33620c4..0000000
1301--- a/archive-http/src/templates/index.html
1302+++ /dev/null
1303 @@ -1,12 +0,0 @@
1304- {% include "header.html" %}
1305- <div class="entry">
1306- <h1>{{title}}</h1>
1307- <div class="body">
1308- <ul>
1309- {% for l in lists %}
1310- <li><a href="{{ root_prefix|safe }}/lists/{{ l.list.pk }}/">{{l.title}}</a></li>
1311- {% endfor %}
1312- </ul>
1313- </div>
1314- </div>
1315- {% include "footer.html" %}
1316 diff --git a/archive-http/src/templates/list.html b/archive-http/src/templates/list.html
1317deleted file mode 100644
1318index 3133a3b..0000000
1319--- a/archive-http/src/templates/list.html
1320+++ /dev/null
1321 @@ -1,82 +0,0 @@
1322- {% include "header.html" %}
1323- <div class="body">
1324- {% if preamble %}
1325- <div id="preamble" class="preamble">
1326- {% if preamble.custom %}
1327- {{ preamble.custom|safe }}
1328- {% else %}
1329- {% if not post_policy.no_subscriptions %}
1330- <h2 id="subscribe">Subscribe</h2>
1331- {% set subscription_mailto=list.subscription_mailto() %}
1332- {% if subscription_mailto %}
1333- {% if subscription_mailto.subject %}
1334- <p>
1335- <a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
1336- </p>
1337- {% else %}
1338- <p>
1339- <a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
1340- </p>
1341- {% endif %}
1342- {% else %}
1343- <p>List is not open for subscriptions.</p>
1344- {% endif %}
1345-
1346- {% set unsubscription_mailto=list.unsubscription_mailto() %}
1347- {% if unsubscription_mailto %}
1348- <h2 id="unsubscribe">Unsubscribe</h2>
1349- {% if unsubscription_mailto.subject %}
1350- <p>
1351- <a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
1352- </p>
1353- {% else %}
1354- <p>
1355- <a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
1356- </p>
1357- {% endif %}
1358- {% endif %}
1359- {% endif %}
1360-
1361- <h2 id="post">Post</h2>
1362- {% if post_policy.announce_only %}
1363- <p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
1364- {% elif post_policy.subscription_only %}
1365- <p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
1366- <p>If you are subscribed, you can send new posts to:
1367- <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
1368- </p>
1369- {% elif post_policy.approval_needed or post_policy.no_subscriptions %}
1370- <p>List is open to all posts <em>after approval</em> by the list owners.</p>
1371- <p>You can send new posts to:
1372- <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
1373- </p>
1374- {% else %}
1375- <p>List is not open for submissions.</p>
1376- {% endif %}
1377- {% endif %}
1378- </div>
1379- <hr />
1380- {% endif %}
1381- <div class="list">
1382- <h2 id="calendar">Calendar</h2>
1383- <div class="calendar">
1384- {%- from "calendar.html" import cal %}
1385- {% for date in months %}
1386- {{ cal(date, hists, root_prefix, list.pk) }}
1387- {% endfor %}
1388- </div>
1389- <hr />
1390- <h2 id="posts">Posts</h2>
1391- <div class="posts">
1392- <p>{{ posts | length }} post(s)</p>
1393- {% for post in posts %}
1394- <div class="entry">
1395- <span class="subject"><a href="{{ root_prefix|safe }}/list/{{post.list}}/{{ post.message_id }}.html">{{ post.subject }}</a></span>
1396- <span class="metadata">👤&nbsp;<span class="from">{{ post.address }}</span> 📆&nbsp;<span class="date">{{ post.datetime }}</span></span>
1397- <span class="metadata">🪪 &nbsp;<span class="message-id">{{ post.message_id }}</span></span>
1398- </div>
1399- {% endfor %}
1400- </div>
1401- </div>
1402- </div>
1403- {% include "footer.html" %}
1404 diff --git a/archive-http/src/templates/lists.html b/archive-http/src/templates/lists.html
1405deleted file mode 100644
1406index 825c17b..0000000
1407--- a/archive-http/src/templates/lists.html
1408+++ /dev/null
1409 @@ -1,12 +0,0 @@
1410- {% include "header.html" %}
1411- <div class="body">
1412- <p>{{lists|length}} lists</p>
1413- <div class="entry">
1414- <ul class="lists">
1415- {% for l in lists %}
1416- <li><a href="{{ root_prefix|safe }}/lists/{{ l.list.pk }}/">{{l.title}}</a></li>
1417- {% endfor %}
1418- </ul>
1419- </div>
1420- </div>
1421- {% include "footer.html" %}
1422 diff --git a/archive-http/src/templates/menu.html b/archive-http/src/templates/menu.html
1423deleted file mode 100644
1424index 687a36e..0000000
1425--- a/archive-http/src/templates/menu.html
1426+++ /dev/null
1427 @@ -1,11 +0,0 @@
1428- <nav aria-label="Breadcrumb" class="breadcrumb">
1429- <ul>
1430- {% for crumb in crumbs %}
1431- {% if loop.last %}
1432- <li><span aria-current="page">{{ crumb.label }}</span></li>
1433- {% else %}
1434- <li><a href="{{ crumb.url }}">{{ crumb.label }}</a></li>
1435- {% endif %}
1436- {% endfor %}
1437- </ul>
1438- </nav>
1439 diff --git a/archive-http/src/templates/post.html b/archive-http/src/templates/post.html
1440deleted file mode 100644
1441index c5bf155..0000000
1442--- a/archive-http/src/templates/post.html
1443+++ /dev/null
1444 @@ -1,42 +0,0 @@
1445- {% include "header.html" %}
1446- <div class="body">
1447- <h2>{{trimmed_subject}}</h2>
1448- <table class="headers">
1449- <tr>
1450- <th scope="row">List</th>
1451- <td class="faded">{{ list.id }}</td>
1452- </tr>
1453- <tr>
1454- <th scope="row">From</th>
1455- <td>{{ from }}</td>
1456- </tr>
1457- <tr>
1458- <th scope="row">To</th>
1459- <td class="faded">{{ to }}</td>
1460- </tr>
1461- <tr>
1462- <th scope="row">Subject</th>
1463- <td>{{ subject }}</td>
1464- </tr>
1465- <tr>
1466- <th scope="row">Date</th>
1467- <td class="faded">{{ date }}</td>
1468- </tr>
1469- {% if in_reply_to %}
1470- <tr>
1471- <th scope="row">In-Reply-To</th>
1472- <td class="faded message-id"><a href="{{ root_prefix|safe }}/list/{{list.pk}}/{{ in_reply_to }}.html">{{ in_reply_to }}</a></td>
1473- </tr>
1474- {% endif %}
1475- {% if references %}
1476- <tr>
1477- <th scope="row">References</th>
1478- <td>{% for r in references %}<span class="faded message-id"><a href="{{ root_prefix|safe }}/list/{{list.pk}}/{{ r }}.html">{{ r }}</a></span>{% endfor %}</td>
1479- </tr>
1480- {% endif %}
1481- </table>
1482- <div class="post-body">
1483- <pre>{{body}}</pre>
1484- </div>
1485- </div>
1486- {% include "footer.html" %}
1487 diff --git a/archive-http/src/utils.rs b/archive-http/src/utils.rs
1488deleted file mode 100644
1489index 71905b5..0000000
1490--- a/archive-http/src/utils.rs
1491+++ /dev/null
1492 @@ -1,207 +0,0 @@
1493- /*
1494- * This file is part of mailpot
1495- *
1496- * Copyright 2020 - Manos Pitsidianakis
1497- *
1498- * This program is free software: you can redistribute it and/or modify
1499- * it under the terms of the GNU Affero General Public License as
1500- * published by the Free Software Foundation, either version 3 of the
1501- * License, or (at your option) any later version.
1502- *
1503- * This program is distributed in the hope that it will be useful,
1504- * but WITHOUT ANY WARRANTY; without even the implied warranty of
1505- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1506- * GNU Affero General Public License for more details.
1507- *
1508- * You should have received a copy of the GNU Affero General Public License
1509- * along with this program. If not, see <https://www.gnu.org/licenses/>.
1510- */
1511-
1512- use std::borrow::Cow;
1513-
1514- use chrono::{Datelike, Month};
1515- use mailpot::{models::DbVal, *};
1516- use minijinja::{
1517- value::{Object, Value},
1518- Environment, Error, Source, State,
1519- };
1520-
1521- lazy_static::lazy_static! {
1522- pub static ref TEMPLATES: Environment<'static> = {
1523- let mut env = Environment::new();
1524- env.add_function("calendarize", calendarize);
1525- env.set_source(Source::from_path("src/templates/"));
1526-
1527- env
1528- };
1529- }
1530-
1531- pub trait StripCarets {
1532- fn strip_carets(&self) -> &str;
1533- }
1534-
1535- impl StripCarets for &str {
1536- fn strip_carets(&self) -> &str {
1537- let mut self_ref = self.trim();
1538- if self_ref.starts_with('<') && self_ref.ends_with('>') {
1539- self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
1540- }
1541- self_ref
1542- }
1543- }
1544-
1545- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
1546- pub struct MailingList {
1547- pub pk: i64,
1548- pub name: String,
1549- pub id: String,
1550- pub address: String,
1551- pub description: Option<String>,
1552- pub topics: Vec<String>,
1553- pub archive_url: Option<String>,
1554- pub inner: DbVal<mailpot::models::MailingList>,
1555- }
1556-
1557- impl From<DbVal<mailpot::models::MailingList>> for MailingList {
1558- fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
1559- let DbVal(
1560- mailpot::models::MailingList {
1561- pk,
1562- name,
1563- id,
1564- address,
1565- description,
1566- topics,
1567- archive_url,
1568- },
1569- _,
1570- ) = val.clone();
1571-
1572- Self {
1573- pk,
1574- name,
1575- id,
1576- address,
1577- description,
1578- topics,
1579- archive_url,
1580- inner: val,
1581- }
1582- }
1583- }
1584-
1585- impl std::fmt::Display for MailingList {
1586- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
1587- self.id.fmt(fmt)
1588- }
1589- }
1590-
1591- impl Object for MailingList {
1592- fn kind(&self) -> minijinja::value::ObjectKind {
1593- minijinja::value::ObjectKind::Struct(self)
1594- }
1595-
1596- fn call_method(
1597- &self,
1598- _state: &State,
1599- name: &str,
1600- _args: &[Value],
1601- ) -> std::result::Result<Value, Error> {
1602- match name {
1603- "subscription_mailto" => {
1604- Ok(Value::from_serializable(&self.inner.subscription_mailto()))
1605- }
1606- "unsubscription_mailto" => Ok(Value::from_serializable(
1607- &self.inner.unsubscription_mailto(),
1608- )),
1609- _ => Err(Error::new(
1610- minijinja::ErrorKind::UnknownMethod,
1611- format!("aaaobject has no method named {name}"),
1612- )),
1613- }
1614- }
1615- }
1616-
1617- impl minijinja::value::StructObject for MailingList {
1618- fn get_field(&self, name: &str) -> Option<Value> {
1619- match name {
1620- "pk" => Some(Value::from_serializable(&self.pk)),
1621- "name" => Some(Value::from_serializable(&self.name)),
1622- "id" => Some(Value::from_serializable(&self.id)),
1623- "address" => Some(Value::from_serializable(&self.address)),
1624- "description" => Some(Value::from_serializable(&self.description)),
1625- "topics" => Some(Value::from_serializable(&self.topics)),
1626- "archive_url" => Some(Value::from_serializable(&self.archive_url)),
1627- _ => None,
1628- }
1629- }
1630-
1631- fn static_fields(&self) -> Option<&'static [&'static str]> {
1632- Some(
1633- &[
1634- "pk",
1635- "name",
1636- "id",
1637- "address",
1638- "description",
1639- "topics",
1640- "archive_url",
1641- ][..],
1642- )
1643- }
1644- }
1645-
1646- pub fn calendarize(_state: &State, args: Value, hists: Value) -> std::result::Result<Value, Error> {
1647- macro_rules! month {
1648- ($int:expr) => {{
1649- let int = $int;
1650- match int {
1651- 1 => Month::January.name(),
1652- 2 => Month::February.name(),
1653- 3 => Month::March.name(),
1654- 4 => Month::April.name(),
1655- 5 => Month::May.name(),
1656- 6 => Month::June.name(),
1657- 7 => Month::July.name(),
1658- 8 => Month::August.name(),
1659- 9 => Month::September.name(),
1660- 10 => Month::October.name(),
1661- 11 => Month::November.name(),
1662- 12 => Month::December.name(),
1663- _ => unreachable!(),
1664- }
1665- }};
1666- }
1667- let month = args.as_str().unwrap();
1668- let hist = hists
1669- .get_item(&Value::from(month))?
1670- .as_seq()
1671- .unwrap()
1672- .iter()
1673- .map(|v| usize::try_from(v).unwrap())
1674- .collect::<Vec<usize>>();
1675- let sum: usize = hists
1676- .get_item(&Value::from(month))?
1677- .as_seq()
1678- .unwrap()
1679- .iter()
1680- .map(|v| usize::try_from(v).unwrap())
1681- .sum();
1682- let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
1683- // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
1684- Ok(minijinja::context! {
1685- month_name => month!(date.month()),
1686- month => month,
1687- month_int => date.month() as usize,
1688- year => date.year(),
1689- weeks => crate::cal::calendarize_with_offset(date, 1),
1690- hist => hist,
1691- sum => sum,
1692- })
1693- }
1694-
1695- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
1696- pub struct Crumb {
1697- pub label: Cow<'static, str>,
1698- pub url: Cow<'static, str>,
1699- }
1700 diff --git a/cli/Cargo.toml b/cli/Cargo.toml
1701deleted file mode 100644
1702index 00f6248..0000000
1703--- a/cli/Cargo.toml
1704+++ /dev/null
1705 @@ -1,39 +0,0 @@
1706- [package]
1707- name = "mailpot-cli"
1708- version = "0.1.1"
1709- authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
1710- edition = "2021"
1711- license = "LICENSE"
1712- readme = "README.md"
1713- description = "mailing list manager"
1714- repository = "https://github.com/meli/mailpot"
1715- keywords = ["mail", "mailing-lists"]
1716- categories = ["email"]
1717- default-run = "mpot"
1718-
1719- [[bin]]
1720- name = "mpot"
1721- path = "src/main.rs"
1722- doc-scrape-examples = true
1723-
1724- [dependencies]
1725- base64 = { version = "0.21" }
1726- clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] }
1727- log = "0.4"
1728- mailpot = { version = "^0.1", path = "../core" }
1729- serde = { version = "^1", features = ["derive", ] }
1730- serde_json = "^1"
1731- stderrlog = { version = "^0.6" }
1732- ureq = { version = "2.6", default-features = false }
1733-
1734- [dev-dependencies]
1735- assert_cmd = "2"
1736- mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
1737- predicates = "3"
1738- tempfile = { version = "3.9" }
1739-
1740- [build-dependencies]
1741- clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "wrap_help", "help", "usage", "error-context", "suggestions"] }
1742- clap_mangen = "0.2.10"
1743- mailpot = { version = "^0.1", path = "../core" }
1744- stderrlog = { version = "^0.6" }
1745 diff --git a/cli/README.md b/cli/README.md
1746deleted file mode 100644
1747index f5e323d..0000000
1748--- a/cli/README.md
1749+++ /dev/null
1750 @@ -1,5 +0,0 @@
1751- # mailpot-cli
1752-
1753- ```shell
1754- cargo run --bin mpot -- help
1755- ```
1756 diff --git a/cli/build.rs b/cli/build.rs
1757deleted file mode 100644
1758index 2f0db6d..0000000
1759--- a/cli/build.rs
1760+++ /dev/null
1761 @@ -1,524 +0,0 @@
1762- /*
1763- * This file is part of mailpot
1764- *
1765- * Copyright 2020 - Manos Pitsidianakis
1766- *
1767- * This program is free software: you can redistribute it and/or modify
1768- * it under the terms of the GNU Affero General Public License as
1769- * published by the Free Software Foundation, either version 3 of the
1770- * License, or (at your option) any later version.
1771- *
1772- * This program is distributed in the hope that it will be useful,
1773- * but WITHOUT ANY WARRANTY; without even the implied warranty of
1774- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1775- * GNU Affero General Public License for more details.
1776- *
1777- * You should have received a copy of the GNU Affero General Public License
1778- * along with this program. If not, see <https://www.gnu.org/licenses/>.
1779- */
1780-
1781- use std::{
1782- collections::{hash_map::RandomState, HashSet, VecDeque},
1783- hash::{BuildHasher, Hasher},
1784- io::Write,
1785- };
1786-
1787- use clap::{ArgAction, CommandFactory};
1788- use clap_mangen::{roff, Man};
1789- use roff::{bold, italic, roman, Inline, Roff};
1790-
1791- include!("src/args.rs");
1792-
1793- fn main() -> std::io::Result<()> {
1794- println!("cargo:rerun-if-changed=./src/lib.rs");
1795- println!("cargo:rerun-if-changed=./build.rs");
1796- std::env::set_current_dir("..").expect("could not chdir('..')");
1797-
1798- let out_dir = PathBuf::from("./docs/");
1799-
1800- let cmd = Opt::command();
1801-
1802- let man = Man::new(cmd.clone()).title("mpot");
1803- let mut buffer: Vec<u8> = Default::default();
1804- man.render_title(&mut buffer)?;
1805- man.render_name_section(&mut buffer)?;
1806- man.render_synopsis_section(&mut buffer)?;
1807- man.render_description_section(&mut buffer)?;
1808-
1809- let mut roff = Roff::default();
1810- options(&mut roff, &cmd);
1811- roff.to_writer(&mut buffer)?;
1812-
1813- render_quick_start_section(&mut buffer)?;
1814- render_subcommands_section(&mut buffer)?;
1815-
1816- let mut visited = HashSet::new();
1817-
1818- let mut stack = VecDeque::new();
1819- let mut order = VecDeque::new();
1820- stack.push_back(vec![&cmd]);
1821- let s = RandomState::new();
1822-
1823- 'stack: while let Some(cmds) = stack.pop_front() {
1824- for sub in cmds.last().unwrap().get_subcommands() {
1825- let mut hasher = s.build_hasher();
1826- for c in cmds.iter() {
1827- hasher.write(c.get_name().as_bytes());
1828- }
1829- hasher.write(sub.get_name().as_bytes());
1830- if visited.insert(hasher.finish()) {
1831- let mut sub_cmds = cmds.clone();
1832- sub_cmds.push(sub);
1833- order.push_back(sub_cmds.clone());
1834- stack.push_front(cmds);
1835- stack.push_front(sub_cmds);
1836- continue 'stack;
1837- }
1838- }
1839- }
1840-
1841- while let Some(mut subs) = order.pop_front() {
1842- let sub = subs.pop().unwrap();
1843- render_subcommand(&subs, sub, &mut buffer)?;
1844- }
1845-
1846- man.render_authors_section(&mut buffer)?;
1847-
1848- std::fs::write(out_dir.join("mpot.1"), buffer)?;
1849-
1850- Ok(())
1851- }
1852-
1853- fn render_quick_start_section(w: &mut dyn Write) -> Result<(), std::io::Error> {
1854- let mut roff = Roff::default();
1855- let heading = "QUICK START";
1856- roff.control("SH", [heading]);
1857- let tutorial = r#"mailpot saves its data in a sqlite3 file. To define the location of the sqlite3 file we need a configuration file, which can be generated with:
1858-
1859- mpot sample-config > conf.toml
1860-
1861- Mailing lists can now be created:
1862-
1863- mpot -c conf.toml create-list --name "my first list" --id mylist --address mylist@example.com
1864-
1865- You can list all the mailing lists with:
1866-
1867- mpot -c conf.toml list-lists
1868-
1869- You should add yourself as the list owner:
1870-
1871- mpot -c conf.toml list mylist add-list-owner --address myself@example.com --name "Nemo"
1872-
1873- And also enable posting and subscriptions by setting list policies:
1874-
1875- mpot -c conf.toml list mylist add-policy --subscriber-only
1876-
1877- mpot -c conf.toml list mylist add-subscribe-policy --request --send-confirmation
1878-
1879- To post on a mailing list or submit a list request, pipe a raw e-mail into STDIN:
1880-
1881- mpot -c conf.toml post
1882-
1883- You can configure your mail server to redirect e-mails addressed to your mailing lists to this command.
1884-
1885- For postfix, you can automatically generate this configuration with:
1886-
1887- mpot -c conf.toml print-postfix-config --user myself --binary-path /path/to/mpot
1888-
1889- This will print the following:
1890-
1891- - content of `transport_maps` and `local_recipient_maps`
1892-
1893- The output must be saved in a plain text file.
1894- Map output should be added to transport_maps and local_recipient_maps parameters in postfix's main.cf.
1895- To make postfix be able to read them, the postmap application must be executed with the
1896- path to the map file as its sole argument.
1897-
1898- postmap /path/to/mylist_maps
1899-
1900- postmap is usually distributed along with the other postfix binaries.
1901-
1902- - `master.cf` service entry
1903- The output must be entered in the master.cf file.
1904- See <https://www.postfix.org/master.5.html>.
1905-
1906- "#;
1907- for line in tutorial.lines() {
1908- roff.text([roman(line.trim())]);
1909- }
1910- roff.to_writer(w)
1911- }
1912- fn render_subcommands_section(w: &mut dyn Write) -> Result<(), std::io::Error> {
1913- let mut roff = Roff::default();
1914- let heading = "SUBCOMMANDS";
1915- roff.control("SH", [heading]);
1916- roff.to_writer(w)
1917- }
1918-
1919- fn render_subcommand(
1920- parents: &[&clap::Command],
1921- sub: &clap::Command,
1922- w: &mut dyn Write,
1923- ) -> Result<(), std::io::Error> {
1924- let mut roff = Roff::default();
1925- _render_subcommand_full(parents, sub, &mut roff);
1926- options(&mut roff, sub);
1927- roff.to_writer(w)
1928- }
1929-
1930- fn _render_subcommand_full(parents: &[&clap::Command], sub: &clap::Command, roff: &mut Roff) {
1931- roff.control("\\fB", []);
1932- roff.control(
1933- "SS",
1934- parents
1935- .iter()
1936- .map(|cmd| cmd.get_name())
1937- .chain(std::iter::once(sub.get_name()))
1938- .collect::<Vec<_>>(),
1939- );
1940- roff.control("\\fR", []);
1941- roff.text([Inline::LineBreak]);
1942-
1943- synopsis(roff, parents, sub);
1944- roff.text([Inline::LineBreak]);
1945-
1946- if let Some(about) = sub.get_about().or_else(|| sub.get_long_about()) {
1947- let about = about.to_string();
1948- let mut iter = about.lines();
1949- let last = iter.nth_back(0);
1950- for line in iter {
1951- roff.text([roman(line.trim())]);
1952- }
1953- if let Some(line) = last {
1954- roff.text([roman(format!("{}.", line.trim()))]);
1955- }
1956- }
1957- }
1958-
1959- fn synopsis(roff: &mut Roff, parents: &[&clap::Command], sub: &clap::Command) {
1960- let mut line = parents
1961- .iter()
1962- .flat_map(|cmd| vec![roman(cmd.get_name()), roman(" ")].into_iter())
1963- .chain(std::iter::once(roman(sub.get_name())))
1964- .chain(std::iter::once(roman(" ")))
1965- .collect::<Vec<_>>();
1966- let arguments = sub
1967- .get_arguments()
1968- .filter(|i| !i.is_hide_set())
1969- .collect::<Vec<_>>();
1970- if arguments.is_empty() && sub.get_positionals().count() == 0 {
1971- return;
1972- }
1973-
1974- roff.text([Inline::LineBreak]);
1975-
1976- for opt in arguments {
1977- match (opt.get_short(), opt.get_long()) {
1978- (Some(short), Some(long)) => {
1979- let (lhs, rhs) = option_markers(opt);
1980- line.push(roman(lhs));
1981- line.push(roman(format!("-{short}")));
1982- if let Some(value) = opt.get_value_names() {
1983- line.push(roman(" "));
1984- line.push(italic(value.join(" ")));
1985- }
1986-
1987- line.push(roman("|"));
1988- line.push(roman(format!("--{long}",)));
1989- line.push(roman(rhs));
1990- }
1991- (Some(short), None) => {
1992- let (lhs, rhs) = option_markers_single(opt);
1993- line.push(roman(lhs));
1994- line.push(roman(format!("-{short}")));
1995- if let Some(value) = opt.get_value_names() {
1996- line.push(roman(" "));
1997- line.push(italic(value.join(" ")));
1998- }
1999- line.push(roman(rhs));
2000- }
2001- (None, Some(long)) => {
2002- let (lhs, rhs) = option_markers_single(opt);
2003- line.push(roman(lhs));
2004- line.push(roman(format!("--{long}")));
2005- if let Some(value) = opt.get_value_names() {
2006- line.push(roman(" "));
2007- line.push(italic(value.join(" ")));
2008- }
2009- line.push(roman(rhs));
2010- }
2011- (None, None) => continue,
2012- };
2013-
2014- if matches!(opt.get_action(), ArgAction::Count) {
2015- line.push(roman("..."))
2016- }
2017- line.push(roman(" "));
2018- }
2019-
2020- for arg in sub.get_positionals() {
2021- let (lhs, rhs) = option_markers_single(arg);
2022- line.push(roman(lhs));
2023- if let Some(value) = arg.get_value_names() {
2024- line.push(italic(value.join(" ")));
2025- } else {
2026- line.push(italic(arg.get_id().as_str()));
2027- }
2028- line.push(roman(rhs));
2029- line.push(roman(" "));
2030- }
2031-
2032- roff.text(line);
2033- }
2034-
2035- fn options(roff: &mut Roff, cmd: &clap::Command) {
2036- let items: Vec<_> = cmd.get_arguments().filter(|i| !i.is_hide_set()).collect();
2037-
2038- for pos in items.iter().filter(|a| a.is_positional()) {
2039- let mut header = vec![];
2040- let (lhs, rhs) = option_markers_single(pos);
2041- header.push(roman(lhs));
2042- if let Some(value) = pos.get_value_names() {
2043- header.push(italic(value.join(" ")));
2044- } else {
2045- header.push(italic(pos.get_id().as_str()));
2046- };
2047- header.push(roman(rhs));
2048-
2049- if let Some(defs) = option_default_values(pos) {
2050- header.push(roman(format!(" {defs}")));
2051- }
2052-
2053- let mut body = vec![];
2054- let mut arg_help_written = false;
2055- if let Some(help) = option_help(pos) {
2056- arg_help_written = true;
2057- let mut help = help.to_string();
2058- if !help.ends_with('.') {
2059- help.push('.');
2060- }
2061- body.push(roman(help));
2062- }
2063-
2064- roff.control("TP", []);
2065- roff.text(header);
2066- roff.text(body);
2067-
2068- if let Some(env) = option_environment(pos) {
2069- roff.control("RS", []);
2070- roff.text(env);
2071- roff.control("RE", []);
2072- }
2073- // If possible options are available
2074- if let Some((possible_values_text, with_help)) = get_possible_values(pos) {
2075- if arg_help_written {
2076- // It looks nice to have a separation between the help and the values
2077- roff.text([Inline::LineBreak]);
2078- }
2079- if with_help {
2080- roff.text([Inline::LineBreak, italic("Possible values:")]);
2081-
2082- // Need to indent twice to get it to look right, because .TP heading indents,
2083- // but that indent doesn't Carry over to the .IP for the
2084- // bullets. The standard shift size is 7 for terminal devices
2085- roff.control("RS", ["14"]);
2086- for line in possible_values_text {
2087- roff.control("IP", ["\\(bu", "2"]);
2088- roff.text([roman(line)]);
2089- }
2090- roff.control("RE", []);
2091- } else {
2092- let possible_value_text: Vec<Inline> = vec![
2093- Inline::LineBreak,
2094- roman("["),
2095- italic("possible values: "),
2096- roman(possible_values_text.join(", ")),
2097- roman("]"),
2098- ];
2099- roff.text(possible_value_text);
2100- }
2101- }
2102- }
2103-
2104- for opt in items.iter().filter(|a| !a.is_positional()) {
2105- let mut header = match (opt.get_short(), opt.get_long()) {
2106- (Some(short), Some(long)) => {
2107- vec![short_option(short), roman(", "), long_option(long)]
2108- }
2109- (Some(short), None) => vec![short_option(short)],
2110- (None, Some(long)) => vec![long_option(long)],
2111- (None, None) => vec![],
2112- };
2113-
2114- if opt.get_action().takes_values() {
2115- if let Some(value) = &opt.get_value_names() {
2116- header.push(roman(" "));
2117- header.push(italic(value.join(" ")));
2118- }
2119- }
2120-
2121- if let Some(defs) = option_default_values(opt) {
2122- header.push(roman(" "));
2123- header.push(roman(defs));
2124- }
2125-
2126- let mut body = vec![];
2127- let mut arg_help_written = false;
2128- if let Some(help) = option_help(opt) {
2129- arg_help_written = true;
2130- let mut help = help.to_string();
2131- if !help.as_str().ends_with('.') {
2132- help.push('.');
2133- }
2134-
2135- body.push(roman(help));
2136- }
2137-
2138- roff.control("TP", []);
2139- roff.text(header);
2140- roff.text(body);
2141-
2142- if let Some((possible_values_text, with_help)) = get_possible_values(opt) {
2143- if arg_help_written {
2144- // It looks nice to have a separation between the help and the values
2145- roff.text([Inline::LineBreak, Inline::LineBreak]);
2146- }
2147- if with_help {
2148- roff.text([Inline::LineBreak, italic("Possible values:")]);
2149-
2150- // Need to indent twice to get it to look right, because .TP heading indents,
2151- // but that indent doesn't Carry over to the .IP for the
2152- // bullets. The standard shift size is 7 for terminal devices
2153- roff.control("RS", ["14"]);
2154- for line in possible_values_text {
2155- roff.control("IP", ["\\(bu", "2"]);
2156- roff.text([roman(line)]);
2157- }
2158- roff.control("RE", []);
2159- } else {
2160- let possible_value_text: Vec<Inline> = vec![
2161- Inline::LineBreak,
2162- roman("["),
2163- italic("possible values: "),
2164- roman(possible_values_text.join(", ")),
2165- roman("]"),
2166- ];
2167- roff.text(possible_value_text);
2168- }
2169- }
2170-
2171- if let Some(env) = option_environment(opt) {
2172- roff.control("RS", []);
2173- roff.text(env);
2174- roff.control("RE", []);
2175- }
2176- }
2177- }
2178-
2179- fn option_markers(opt: &clap::Arg) -> (&'static str, &'static str) {
2180- markers(opt.is_required_set())
2181- }
2182-
2183- fn option_markers_single(opt: &clap::Arg) -> (&'static str, &'static str) {
2184- if opt.is_required_set() {
2185- ("", "")
2186- } else {
2187- markers(opt.is_required_set())
2188- }
2189- }
2190-
2191- fn markers(required: bool) -> (&'static str, &'static str) {
2192- if required {
2193- ("{", "}")
2194- } else {
2195- ("[", "]")
2196- }
2197- }
2198-
2199- fn short_option(opt: char) -> Inline {
2200- roman(format!("-{opt}"))
2201- }
2202-
2203- fn long_option(opt: &str) -> Inline {
2204- roman(format!("--{opt}"))
2205- }
2206-
2207- fn option_help(opt: &clap::Arg) -> Option<&clap::builder::StyledStr> {
2208- if !opt.is_hide_long_help_set() {
2209- let long_help = opt.get_long_help();
2210- if long_help.is_some() {
2211- return long_help;
2212- }
2213- }
2214- if !opt.is_hide_short_help_set() {
2215- return opt.get_help();
2216- }
2217-
2218- None
2219- }
2220-
2221- fn option_environment(opt: &clap::Arg) -> Option<Vec<Inline>> {
2222- if opt.is_hide_env_set() {
2223- return None;
2224- } else if let Some(env) = opt.get_env() {
2225- return Some(vec![
2226- roman("May also be specified with the "),
2227- bold(env.to_string_lossy().into_owned()),
2228- roman(" environment variable. "),
2229- ]);
2230- }
2231-
2232- None
2233- }
2234-
2235- fn option_default_values(opt: &clap::Arg) -> Option<String> {
2236- if opt.is_hide_default_value_set() || !opt.get_action().takes_values() {
2237- return None;
2238- } else if !opt.get_default_values().is_empty() {
2239- let values = opt
2240- .get_default_values()
2241- .iter()
2242- .map(|s| s.to_string_lossy())
2243- .collect::<Vec<_>>()
2244- .join(",");
2245-
2246- return Some(format!("[default: {values}]"));
2247- }
2248-
2249- None
2250- }
2251-
2252- fn get_possible_values(arg: &clap::Arg) -> Option<(Vec<String>, bool)> {
2253- let possibles = &arg.get_possible_values();
2254- let possibles: Vec<&clap::builder::PossibleValue> =
2255- possibles.iter().filter(|pos| !pos.is_hide_set()).collect();
2256-
2257- if !(possibles.is_empty() || arg.is_hide_possible_values_set()) {
2258- return Some(format_possible_values(&possibles));
2259- }
2260- None
2261- }
2262-
2263- fn format_possible_values(possibles: &Vec<&clap::builder::PossibleValue>) -> (Vec<String>, bool) {
2264- let mut lines = vec![];
2265- let with_help = possibles.iter().any(|p| p.get_help().is_some());
2266- if with_help {
2267- for value in possibles {
2268- let val_name = value.get_name();
2269- match value.get_help() {
2270- Some(help) => lines.push(format!(
2271- "{val_name}: {help}{period}",
2272- period = if help.to_string().ends_with('.') {
2273- ""
2274- } else {
2275- "."
2276- }
2277- )),
2278- None => lines.push(val_name.to_string()),
2279- }
2280- }
2281- } else {
2282- lines.append(&mut possibles.iter().map(|p| p.get_name().to_string()).collect());
2283- }
2284- (lines, with_help)
2285- }
2286 diff --git a/cli/rustfmt.toml b/cli/rustfmt.toml
2287deleted file mode 120000
2288index 39f97b0..0000000
2289--- a/cli/rustfmt.toml
2290+++ /dev/null
2291 @@ -1 +0,0 @@
2292- ../rustfmt.toml
2293\ No newline at end of file
2294 diff --git a/cli/src/args.rs b/cli/src/args.rs
2295deleted file mode 100644
2296index 8414783..0000000
2297--- a/cli/src/args.rs
2298+++ /dev/null
2299 @@ -1,571 +0,0 @@
2300- /*
2301- * This file is part of mailpot
2302- *
2303- * Copyright 2020 - Manos Pitsidianakis
2304- *
2305- * This program is free software: you can redistribute it and/or modify
2306- * it under the terms of the GNU Affero General Public License as
2307- * published by the Free Software Foundation, either version 3 of the
2308- * License, or (at your option) any later version.
2309- *
2310- * This program is distributed in the hope that it will be useful,
2311- * but WITHOUT ANY WARRANTY; without even the implied warranty of
2312- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2313- * GNU Affero General Public License for more details.
2314- *
2315- * You should have received a copy of the GNU Affero General Public License
2316- * along with this program. If not, see <https://www.gnu.org/licenses/>.
2317- */
2318-
2319- pub use std::path::PathBuf;
2320-
2321- pub use clap::{builder::TypedValueParser, Args, Parser, Subcommand};
2322-
2323- #[derive(Debug, Parser)]
2324- #[command(
2325- name = "mpot",
2326- about = "mailing list manager",
2327- long_about = "Tool for mailpot mailing list management.",
2328- before_long_help = "GNU Affero version 3 or later <https://www.gnu.org/licenses/>",
2329- author,
2330- version
2331- )]
2332- pub struct Opt {
2333- /// Print logs.
2334- #[arg(short, long)]
2335- pub debug: bool,
2336- /// Configuration file to use.
2337- #[arg(short, long, value_parser)]
2338- pub config: Option<PathBuf>,
2339- #[command(subcommand)]
2340- pub cmd: Command,
2341- /// Silence all output.
2342- #[arg(short, long)]
2343- pub quiet: bool,
2344- /// Verbose mode (-v, -vv, -vvv, etc).
2345- #[arg(short, long, action = clap::ArgAction::Count)]
2346- pub verbose: u8,
2347- /// Debug log timestamp (sec, ms, ns, none).
2348- #[arg(short, long)]
2349- pub ts: Option<stderrlog::Timestamp>,
2350- }
2351-
2352- #[derive(Debug, Subcommand)]
2353- pub enum Command {
2354- /// Prints a sample config file to STDOUT.
2355- ///
2356- /// You can generate a new configuration file by writing the output to a
2357- /// file, e.g: mpot sample-config --with-smtp > config.toml
2358- SampleConfig {
2359- /// Use an SMTP connection instead of a shell process.
2360- #[arg(long)]
2361- with_smtp: bool,
2362- },
2363- /// Dumps database data to STDOUT.
2364- DumpDatabase,
2365- /// Lists all registered mailing lists.
2366- ListLists,
2367- /// Mailing list management.
2368- List {
2369- /// Selects mailing list to operate on.
2370- list_id: String,
2371- #[command(subcommand)]
2372- cmd: ListCommand,
2373- },
2374- /// Create new list.
2375- CreateList {
2376- /// List name.
2377- #[arg(long)]
2378- name: String,
2379- /// List ID.
2380- #[arg(long)]
2381- id: String,
2382- /// List e-mail address.
2383- #[arg(long)]
2384- address: String,
2385- /// List description.
2386- #[arg(long)]
2387- description: Option<String>,
2388- /// List archive URL.
2389- #[arg(long)]
2390- archive_url: Option<String>,
2391- },
2392- /// Post message from STDIN to list.
2393- Post {
2394- /// Show e-mail processing result without actually consuming it.
2395- #[arg(long)]
2396- dry_run: bool,
2397- },
2398- /// Flush outgoing e-mail queue.
2399- FlushQueue {
2400- /// Show e-mail processing result without actually consuming it.
2401- #[arg(long)]
2402- dry_run: bool,
2403- },
2404- /// Processed mail is stored in queues.
2405- Queue {
2406- #[arg(long, value_parser = QueueValueParser)]
2407- queue: mailpot::queue::Queue,
2408- #[command(subcommand)]
2409- cmd: QueueCommand,
2410- },
2411- /// Import a maildir folder into an existing list.
2412- ImportMaildir {
2413- /// List-ID or primary key value.
2414- list_id: String,
2415- /// Path to a maildir mailbox.
2416- /// Must contain {cur, tmp, new} folders.
2417- #[arg(long, value_parser)]
2418- maildir_path: PathBuf,
2419- },
2420- /// Update postfix maps and master.cf (probably needs root permissions).
2421- UpdatePostfixConfig {
2422- #[arg(short = 'p', long)]
2423- /// Override location of master.cf file (default:
2424- /// /etc/postfix/master.cf)
2425- master_cf: Option<PathBuf>,
2426- #[clap(flatten)]
2427- config: PostfixConfig,
2428- },
2429- /// Print postfix maps and master.cf entry to STDOUT.
2430- ///
2431- /// Map output should be added to transport_maps and local_recipient_maps
2432- /// parameters in postfix's main.cf. It must be saved in a plain text
2433- /// file. To make postfix be able to read them, the postmap application
2434- /// must be executed with the path to the map file as its sole argument.
2435- ///
2436- /// postmap /path/to/mylist_maps
2437- ///
2438- /// postmap is usually distributed along with the other postfix binaries.
2439- ///
2440- /// The master.cf entry must be manually appended to the master.cf file. See <https://www.postfix.org/master.5.html>.
2441- PrintPostfixConfig {
2442- #[clap(flatten)]
2443- config: PostfixConfig,
2444- },
2445- /// All Accounts.
2446- Accounts,
2447- /// Account info.
2448- AccountInfo {
2449- /// Account address.
2450- address: String,
2451- },
2452- /// Add account.
2453- AddAccount {
2454- /// E-mail address.
2455- #[arg(long)]
2456- address: String,
2457- /// SSH public key for authentication.
2458- #[arg(long)]
2459- password: String,
2460- /// Name.
2461- #[arg(long)]
2462- name: Option<String>,
2463- /// Public key.
2464- #[arg(long)]
2465- public_key: Option<String>,
2466- #[arg(long)]
2467- /// Is account enabled.
2468- enabled: Option<bool>,
2469- },
2470- /// Remove account.
2471- RemoveAccount {
2472- #[arg(long)]
2473- /// E-mail address.
2474- address: String,
2475- },
2476- /// Update account info.
2477- UpdateAccount {
2478- /// Address to edit.
2479- address: String,
2480- /// Public key for authentication.
2481- #[arg(long)]
2482- password: Option<String>,
2483- /// Name.
2484- #[arg(long)]
2485- name: Option<Option<String>>,
2486- /// Public key.
2487- #[arg(long)]
2488- public_key: Option<Option<String>>,
2489- #[arg(long)]
2490- /// Is account enabled.
2491- enabled: Option<Option<bool>>,
2492- },
2493- /// Show and fix possible data mistakes or inconsistencies.
2494- Repair {
2495- /// Fix errors (default: false)
2496- #[arg(long, default_value = "false")]
2497- fix: bool,
2498- /// Select all tests (default: false)
2499- #[arg(long, default_value = "false")]
2500- all: bool,
2501- /// Post `datetime` column must have the Date: header value, in RFC2822
2502- /// format.
2503- #[arg(long, default_value = "false")]
2504- datetime_header_value: bool,
2505- /// Remove accounts that have no matching subscriptions.
2506- #[arg(long, default_value = "false")]
2507- remove_empty_accounts: bool,
2508- /// Remove subscription requests that have been accepted.
2509- #[arg(long, default_value = "false")]
2510- remove_accepted_subscription_requests: bool,
2511- /// Warn if a list has no owners.
2512- #[arg(long, default_value = "false")]
2513- warn_list_no_owner: bool,
2514- },
2515- }
2516-
2517- /// Postfix config values.
2518- #[derive(Debug, Args)]
2519- pub struct PostfixConfig {
2520- /// User that runs mailpot when postfix relays a message.
2521- ///
2522- /// Must not be the `postfix` user.
2523- /// Must have permissions to access the database file and the data
2524- /// directory.
2525- #[arg(short, long)]
2526- pub user: String,
2527- /// Group that runs mailpot when postfix relays a message.
2528- /// Optional.
2529- #[arg(short, long)]
2530- pub group: Option<String>,
2531- /// The path to the mailpot binary postfix will execute.
2532- #[arg(long)]
2533- pub binary_path: PathBuf,
2534- /// Limit the number of mailpot instances that can exist at the same time.
2535- ///
2536- /// Default is 1.
2537- #[arg(long, default_value = "1")]
2538- pub process_limit: Option<u64>,
2539- /// The directory in which the map files are saved.
2540- ///
2541- /// Default is `data_path` from [`Configuration`](mailpot::Configuration).
2542- #[arg(long)]
2543- pub map_output_path: Option<PathBuf>,
2544- /// The name of the postfix service name to use.
2545- /// Default is `mailpot`.
2546- ///
2547- /// A postfix service is a daemon managed by the postfix process.
2548- /// Each entry in the `master.cf` configuration file defines a single
2549- /// service.
2550- ///
2551- /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
2552- /// <https://www.postfix.org/master.5.html>.
2553- #[arg(long)]
2554- pub transport_name: Option<String>,
2555- }
2556-
2557- #[derive(Debug, Subcommand)]
2558- pub enum QueueCommand {
2559- /// List.
2560- List,
2561- /// Print entry in RFC5322 or JSON format.
2562- Print {
2563- /// index of entry.
2564- #[arg(long)]
2565- index: Vec<i64>,
2566- },
2567- /// Delete entry and print it in stdout.
2568- Delete {
2569- /// index of entry.
2570- #[arg(long)]
2571- index: Vec<i64>,
2572- },
2573- }
2574-
2575- /// Subscription options.
2576- #[derive(Debug, Args)]
2577- pub struct SubscriptionOptions {
2578- /// Name.
2579- #[arg(long)]
2580- pub name: Option<String>,
2581- /// Send messages as digest.
2582- #[arg(long, default_value = "false")]
2583- pub digest: Option<bool>,
2584- /// Hide message from list when posting.
2585- #[arg(long, default_value = "false")]
2586- pub hide_address: Option<bool>,
2587- /// Hide message from list when posting.
2588- #[arg(long, default_value = "false")]
2589- /// E-mail address verification status.
2590- pub verified: Option<bool>,
2591- #[arg(long, default_value = "true")]
2592- /// Receive confirmation email when posting.
2593- pub receive_confirmation: Option<bool>,
2594- #[arg(long, default_value = "true")]
2595- /// Receive posts from list even if address exists in To or Cc header.
2596- pub receive_duplicates: Option<bool>,
2597- #[arg(long, default_value = "false")]
2598- /// Receive own posts from list.
2599- pub receive_own_posts: Option<bool>,
2600- #[arg(long, default_value = "true")]
2601- /// Is subscription enabled.
2602- pub enabled: Option<bool>,
2603- }
2604-
2605- /// Account options.
2606- #[derive(Debug, Args)]
2607- pub struct AccountOptions {
2608- /// Name.
2609- #[arg(long)]
2610- pub name: Option<String>,
2611- /// Public key.
2612- #[arg(long)]
2613- pub public_key: Option<String>,
2614- #[arg(long)]
2615- /// Is account enabled.
2616- pub enabled: Option<bool>,
2617- }
2618-
2619- #[derive(Debug, Subcommand)]
2620- pub enum ListCommand {
2621- /// List subscriptions of list.
2622- Subscriptions,
2623- /// List subscription requests.
2624- SubscriptionRequests,
2625- /// Add subscription to list.
2626- AddSubscription {
2627- /// E-mail address.
2628- #[arg(long)]
2629- address: String,
2630- #[clap(flatten)]
2631- subscription_options: SubscriptionOptions,
2632- },
2633- /// Remove subscription from list.
2634- RemoveSubscription {
2635- #[arg(long)]
2636- /// E-mail address.
2637- address: String,
2638- },
2639- /// Update subscription info.
2640- UpdateSubscription {
2641- /// Address to edit.
2642- address: String,
2643- #[clap(flatten)]
2644- subscription_options: SubscriptionOptions,
2645- },
2646- /// Accept a subscription request by its primary key.
2647- AcceptSubscriptionRequest {
2648- /// The primary key of the request.
2649- pk: i64,
2650- /// Do not send confirmation e-mail.
2651- #[arg(long, default_value = "false")]
2652- do_not_send_confirmation: bool,
2653- },
2654- /// Send subscription confirmation manually.
2655- SendConfirmationForSubscription {
2656- /// The primary key of the subscription.
2657- pk: i64,
2658- },
2659- /// Add a new post policy.
2660- AddPostPolicy {
2661- #[arg(long)]
2662- /// Only list owners can post.
2663- announce_only: bool,
2664- #[arg(long)]
2665- /// Only subscriptions can post.
2666- subscription_only: bool,
2667- #[arg(long)]
2668- /// Subscriptions can post.
2669- /// Other posts must be approved by list owners.
2670- approval_needed: bool,
2671- #[arg(long)]
2672- /// Anyone can post without restrictions.
2673- open: bool,
2674- #[arg(long)]
2675- /// Allow posts, but handle it manually.
2676- custom: bool,
2677- },
2678- // Remove post policy.
2679- RemovePostPolicy {
2680- #[arg(long)]
2681- /// Post policy primary key.
2682- pk: i64,
2683- },
2684- /// Add subscription policy to list.
2685- AddSubscriptionPolicy {
2686- #[arg(long)]
2687- /// Send confirmation e-mail when subscription is finalized.
2688- send_confirmation: bool,
2689- #[arg(long)]
2690- /// Anyone can subscribe without restrictions.
2691- open: bool,
2692- #[arg(long)]
2693- /// Only list owners can manually add subscriptions.
2694- manual: bool,
2695- #[arg(long)]
2696- /// Anyone can request to subscribe.
2697- request: bool,
2698- #[arg(long)]
2699- /// Allow subscriptions, but handle it manually.
2700- custom: bool,
2701- },
2702- RemoveSubscriptionPolicy {
2703- #[arg(long)]
2704- /// Subscription policy primary key.
2705- pk: i64,
2706- },
2707- /// Add list owner to list.
2708- AddListOwner {
2709- #[arg(long)]
2710- address: String,
2711- #[arg(long)]
2712- name: Option<String>,
2713- },
2714- RemoveListOwner {
2715- #[arg(long)]
2716- /// List owner primary key.
2717- pk: i64,
2718- },
2719- /// Alias for update-subscription --enabled true.
2720- EnableSubscription {
2721- /// Subscription address.
2722- address: String,
2723- },
2724- /// Alias for update-subscription --enabled false.
2725- DisableSubscription {
2726- /// Subscription address.
2727- address: String,
2728- },
2729- /// Update mailing list details.
2730- Update {
2731- /// New list name.
2732- #[arg(long)]
2733- name: Option<String>,
2734- /// New List-ID.
2735- #[arg(long)]
2736- id: Option<String>,
2737- /// New list address.
2738- #[arg(long)]
2739- address: Option<String>,
2740- /// New list description.
2741- #[arg(long)]
2742- description: Option<String>,
2743- /// New list archive URL.
2744- #[arg(long)]
2745- archive_url: Option<String>,
2746- /// New owner address local part.
2747- /// If empty, it defaults to '+owner'.
2748- #[arg(long)]
2749- owner_local_part: Option<String>,
2750- /// New request address local part.
2751- /// If empty, it defaults to '+request'.
2752- #[arg(long)]
2753- request_local_part: Option<String>,
2754- /// Require verification of e-mails for new subscriptions.
2755- ///
2756- /// Subscriptions that are initiated from the subscription's address are
2757- /// verified automatically.
2758- #[arg(long)]
2759- verify: Option<bool>,
2760- /// Public visibility of list.
2761- ///
2762- /// If hidden, the list will not show up in public APIs unless
2763- /// requests to it won't work.
2764- #[arg(long)]
2765- hidden: Option<bool>,
2766- /// Enable or disable the list's functionality.
2767- ///
2768- /// If not enabled, the list will continue to show up in the database
2769- /// but e-mails and requests to it won't work.
2770- #[arg(long)]
2771- enabled: Option<bool>,
2772- },
2773- /// Show mailing list health status.
2774- Health,
2775- /// Show mailing list info.
2776- Info,
2777- /// Import members in a local list from a remote mailman3 REST API instance.
2778- ///
2779- /// To find the id of the remote list, you can check URL/lists.
2780- /// Example with curl:
2781- ///
2782- /// curl --anyauth -u admin:pass "http://localhost:9001/3.0/lists"
2783- ///
2784- /// If you're trying to import an entire list, create it first and then
2785- /// import its users with this command.
2786- ///
2787- /// Example:
2788- /// mpot -c conf.toml list list-general import-members --url "http://localhost:9001/3.0/" --username admin --password password --list-id list-general.example.com --skip-owners --dry-run
2789- ImportMembers {
2790- #[arg(long)]
2791- /// REST HTTP endpoint e.g. http://localhost:9001/3.0/
2792- url: String,
2793- #[arg(long)]
2794- /// REST HTTP Basic Authentication username.
2795- username: String,
2796- #[arg(long)]
2797- /// REST HTTP Basic Authentication password.
2798- password: String,
2799- #[arg(long)]
2800- /// List ID of remote list to query.
2801- list_id: String,
2802- /// Show what would be inserted without performing any changes.
2803- #[arg(long)]
2804- dry_run: bool,
2805- /// Don't import list owners.
2806- #[arg(long)]
2807- skip_owners: bool,
2808- },
2809- }
2810-
2811- #[derive(Clone, Copy, Debug)]
2812- pub struct QueueValueParser;
2813-
2814- impl QueueValueParser {
2815- pub fn new() -> Self {
2816- Self
2817- }
2818- }
2819-
2820- impl TypedValueParser for QueueValueParser {
2821- type Value = mailpot::queue::Queue;
2822-
2823- fn parse_ref(
2824- &self,
2825- cmd: &clap::Command,
2826- arg: Option<&clap::Arg>,
2827- value: &std::ffi::OsStr,
2828- ) -> std::result::Result<Self::Value, clap::Error> {
2829- TypedValueParser::parse(self, cmd, arg, value.to_owned())
2830- }
2831-
2832- fn parse(
2833- &self,
2834- cmd: &clap::Command,
2835- _arg: Option<&clap::Arg>,
2836- value: std::ffi::OsString,
2837- ) -> std::result::Result<Self::Value, clap::Error> {
2838- use std::str::FromStr;
2839-
2840- use clap::error::ErrorKind;
2841-
2842- if value.is_empty() {
2843- return Err(cmd.clone().error(
2844- ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
2845- "queue value required",
2846- ));
2847- }
2848- Self::Value::from_str(value.to_str().ok_or_else(|| {
2849- cmd.clone().error(
2850- ErrorKind::InvalidValue,
2851- "Queue value is not an UTF-8 string",
2852- )
2853- })?)
2854- .map_err(|err| cmd.clone().error(ErrorKind::InvalidValue, err))
2855- }
2856-
2857- fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue>>> {
2858- Some(Box::new(
2859- mailpot::queue::Queue::possible_values()
2860- .iter()
2861- .map(clap::builder::PossibleValue::new),
2862- ))
2863- }
2864- }
2865-
2866- impl Default for QueueValueParser {
2867- fn default() -> Self {
2868- Self::new()
2869- }
2870- }
2871 diff --git a/cli/src/commands.rs b/cli/src/commands.rs
2872deleted file mode 100644
2873index d3f8be5..0000000
2874--- a/cli/src/commands.rs
2875+++ /dev/null
2876 @@ -1,1093 +0,0 @@
2877- /*
2878- * This file is part of mailpot
2879- *
2880- * Copyright 2020 - Manos Pitsidianakis
2881- *
2882- * This program is free software: you can redistribute it and/or modify
2883- * it under the terms of the GNU Affero General Public License as
2884- * published by the Free Software Foundation, either version 3 of the
2885- * License, or (at your option) any later version.
2886- *
2887- * This program is distributed in the hope that it will be useful,
2888- * but WITHOUT ANY WARRANTY; without even the implied warranty of
2889- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2890- * GNU Affero General Public License for more details.
2891- *
2892- * You should have received a copy of the GNU Affero General Public License
2893- * along with this program. If not, see <https://www.gnu.org/licenses/>.
2894- */
2895-
2896- use std::{
2897- collections::hash_map::DefaultHasher,
2898- hash::{Hash, Hasher},
2899- io::{Read, Write},
2900- path::{Path, PathBuf},
2901- process::Stdio,
2902- };
2903-
2904- use mailpot::{
2905- melib,
2906- melib::{maildir::MaildirPathTrait, smol, Envelope, EnvelopeHash},
2907- models::{changesets::*, *},
2908- queue::{Queue, QueueEntry},
2909- transaction::TransactionBehavior,
2910- Connection, Context, Error, ErrorKind, Result,
2911- };
2912-
2913- use crate::{lints::*, *};
2914-
2915- macro_rules! list {
2916- ($db:ident, $list_id:expr) => {{
2917- $db.list_by_id(&$list_id)?.or_else(|| {
2918- $list_id
2919- .parse::<i64>()
2920- .ok()
2921- .map(|pk| $db.list(pk).ok())
2922- .flatten()
2923- .flatten()
2924- })
2925- }};
2926- }
2927-
2928- macro_rules! string_opts {
2929- ($field:ident) => {
2930- if $field.as_deref().map(str::is_empty).unwrap_or(false) {
2931- None
2932- } else {
2933- Some($field)
2934- }
2935- };
2936- }
2937-
2938- pub fn dump_database(db: &mut Connection) -> Result<()> {
2939- let lists = db.lists()?;
2940- let mut stdout = std::io::stdout();
2941- serde_json::to_writer_pretty(&mut stdout, &lists)?;
2942- for l in &lists {
2943- serde_json::to_writer_pretty(
2944- &mut stdout,
2945- &db.list_subscriptions(l.pk)
2946- .context("Could not retrieve list subscriptions.")?,
2947- )?;
2948- }
2949- Ok(())
2950- }
2951-
2952- pub fn list_lists(db: &mut Connection) -> Result<()> {
2953- let lists = db.lists().context("Could not retrieve lists.")?;
2954- if lists.is_empty() {
2955- println!("No lists found.");
2956- } else {
2957- for l in lists {
2958- println!("- {} {:?}", l.id, l);
2959- let list_owners = db
2960- .list_owners(l.pk)
2961- .context("Could not retrieve list owners.")?;
2962- if list_owners.is_empty() {
2963- println!("\tList owners: None");
2964- } else {
2965- println!("\tList owners:");
2966- for o in list_owners {
2967- println!("\t- {}", o);
2968- }
2969- }
2970- if let Some(s) = db
2971- .list_post_policy(l.pk)
2972- .context("Could not retrieve list post policy.")?
2973- {
2974- println!("\tPost policy: {}", s);
2975- } else {
2976- println!("\tPost policy: None");
2977- }
2978- if let Some(s) = db
2979- .list_subscription_policy(l.pk)
2980- .context("Could not retrieve list subscription policy.")?
2981- {
2982- println!("\tSubscription policy: {}", s);
2983- } else {
2984- println!("\tSubscription policy: None");
2985- }
2986- println!();
2987- }
2988- }
2989- Ok(())
2990- }
2991-
2992- pub fn list(db: &mut Connection, list_id: &str, cmd: ListCommand, quiet: bool) -> Result<()> {
2993- let list = match list!(db, list_id) {
2994- Some(v) => v,
2995- None => {
2996- return Err(format!("No list with id or pk {} was found", list_id).into());
2997- }
2998- };
2999- use ListCommand::*;
3000- match cmd {
3001- Subscriptions => {
3002- let subscriptions = db.list_subscriptions(list.pk)?;
3003- if subscriptions.is_empty() {
3004- if !quiet {
3005- println!("No subscriptions found.");
3006- }
3007- } else {
3008- if !quiet {
3009- println!("Subscriptions of list {}", list.id);
3010- }
3011- for l in subscriptions {
3012- println!("- {}", &l);
3013- }
3014- }
3015- }
3016- AddSubscription {
3017- address,
3018- subscription_options:
3019- SubscriptionOptions {
3020- name,
3021- digest,
3022- hide_address,
3023- receive_duplicates,
3024- receive_own_posts,
3025- receive_confirmation,
3026- enabled,
3027- verified,
3028- },
3029- } => {
3030- db.add_subscription(
3031- list.pk,
3032- ListSubscription {
3033- pk: 0,
3034- list: list.pk,
3035- address,
3036- account: None,
3037- name,
3038- digest: digest.unwrap_or(false),
3039- hide_address: hide_address.unwrap_or(false),
3040- receive_confirmation: receive_confirmation.unwrap_or(true),
3041- receive_duplicates: receive_duplicates.unwrap_or(true),
3042- receive_own_posts: receive_own_posts.unwrap_or(false),
3043- enabled: enabled.unwrap_or(true),
3044- verified: verified.unwrap_or(false),
3045- },
3046- )?;
3047- }
3048- RemoveSubscription { address } => {
3049- let mut input = String::new();
3050- loop {
3051- println!(
3052- "Are you sure you want to remove subscription of {} from list {}? [Yy/n]",
3053- address, list
3054- );
3055- input.clear();
3056- std::io::stdin().read_line(&mut input)?;
3057- if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
3058- break;
3059- } else if input.trim() == "n" {
3060- return Ok(());
3061- }
3062- }
3063-
3064- db.remove_subscription(list.pk, &address)?;
3065- }
3066- Health => {
3067- if !quiet {
3068- println!("{} health:", list);
3069- }
3070- let list_owners = db
3071- .list_owners(list.pk)
3072- .context("Could not retrieve list owners.")?;
3073- let post_policy = db
3074- .list_post_policy(list.pk)
3075- .context("Could not retrieve list post policy.")?;
3076- let subscription_policy = db
3077- .list_subscription_policy(list.pk)
3078- .context("Could not retrieve list subscription policy.")?;
3079- if list_owners.is_empty() {
3080- println!("\tList has no owners: you should add at least one.");
3081- } else {
3082- for owner in list_owners {
3083- println!("\tList owner: {}.", owner);
3084- }
3085- }
3086- if let Some(p) = post_policy {
3087- println!("\tList has post policy: {p}.");
3088- } else {
3089- println!("\tList has no post policy: you should add one.");
3090- }
3091- if let Some(p) = subscription_policy {
3092- println!("\tList has subscription policy: {p}.");
3093- } else {
3094- println!("\tList has no subscription policy: you should add one.");
3095- }
3096- }
3097- Info => {
3098- println!("{} info:", list);
3099- let list_owners = db
3100- .list_owners(list.pk)
3101- .context("Could not retrieve list owners.")?;
3102- let post_policy = db
3103- .list_post_policy(list.pk)
3104- .context("Could not retrieve list post policy.")?;
3105- let subscription_policy = db
3106- .list_subscription_policy(list.pk)
3107- .context("Could not retrieve list subscription policy.")?;
3108- let subscriptions = db
3109- .list_subscriptions(list.pk)
3110- .context("Could not retrieve list subscriptions.")?;
3111- if subscriptions.is_empty() {
3112- println!("No subscriptions.");
3113- } else if subscriptions.len() == 1 {
3114- println!("1 subscription.");
3115- } else {
3116- println!("{} subscriptions.", subscriptions.len());
3117- }
3118- if list_owners.is_empty() {
3119- println!("List owners: None");
3120- } else {
3121- println!("List owners:");
3122- for o in list_owners {
3123- println!("\t- {}", o);
3124- }
3125- }
3126- if let Some(s) = post_policy {
3127- println!("Post policy: {s}");
3128- } else {
3129- println!("Post policy: None");
3130- }
3131- if let Some(s) = subscription_policy {
3132- println!("Subscription policy: {s}");
3133- } else {
3134- println!("Subscription policy: None");
3135- }
3136- }
3137- UpdateSubscription {
3138- address,
3139- subscription_options:
3140- SubscriptionOptions {
3141- name,
3142- digest,
3143- hide_address,
3144- receive_duplicates,
3145- receive_own_posts,
3146- receive_confirmation,
3147- enabled,
3148- verified,
3149- },
3150- } => {
3151- let name = if name
3152- .as_ref()
3153- .map(|s: &String| s.is_empty())
3154- .unwrap_or(false)
3155- {
3156- None
3157- } else {
3158- Some(name)
3159- };
3160- let changeset = ListSubscriptionChangeset {
3161- list: list.pk,
3162- address,
3163- account: None,
3164- name,
3165- digest,
3166- verified,
3167- hide_address,
3168- receive_duplicates,
3169- receive_own_posts,
3170- receive_confirmation,
3171- enabled,
3172- };
3173- db.update_subscription(changeset)?;
3174- }
3175- AddPostPolicy {
3176- announce_only,
3177- subscription_only,
3178- approval_needed,
3179- open,
3180- custom,
3181- } => {
3182- let policy = PostPolicy {
3183- pk: 0,
3184- list: list.pk,
3185- announce_only,
3186- subscription_only,
3187- approval_needed,
3188- open,
3189- custom,
3190- };
3191- let new_val = db.set_list_post_policy(policy)?;
3192- println!("Added new policy with pk = {}", new_val.pk());
3193- }
3194- RemovePostPolicy { pk } => {
3195- db.remove_list_post_policy(list.pk, pk)?;
3196- println!("Removed policy with pk = {}", pk);
3197- }
3198- AddSubscriptionPolicy {
3199- send_confirmation,
3200- open,
3201- manual,
3202- request,
3203- custom,
3204- } => {
3205- let policy = SubscriptionPolicy {
3206- pk: 0,
3207- list: list.pk,
3208- send_confirmation,
3209- open,
3210- manual,
3211- request,
3212- custom,
3213- };
3214- let new_val = db.set_list_subscription_policy(policy)?;
3215- println!("Added new subscribe policy with pk = {}", new_val.pk());
3216- }
3217- RemoveSubscriptionPolicy { pk } => {
3218- db.remove_list_subscription_policy(list.pk, pk)?;
3219- println!("Removed subscribe policy with pk = {}", pk);
3220- }
3221- AddListOwner { address, name } => {
3222- let list_owner = ListOwner {
3223- pk: 0,
3224- list: list.pk,
3225- address,
3226- name,
3227- };
3228- let new_val = db.add_list_owner(list_owner)?;
3229- println!("Added new list owner {}", new_val);
3230- }
3231- RemoveListOwner { pk } => {
3232- db.remove_list_owner(list.pk, pk)?;
3233- println!("Removed list owner with pk = {}", pk);
3234- }
3235- EnableSubscription { address } => {
3236- let changeset = ListSubscriptionChangeset {
3237- list: list.pk,
3238- address,
3239- account: None,
3240- name: None,
3241- digest: None,
3242- verified: None,
3243- enabled: Some(true),
3244- hide_address: None,
3245- receive_duplicates: None,
3246- receive_own_posts: None,
3247- receive_confirmation: None,
3248- };
3249- db.update_subscription(changeset)?;
3250- }
3251- DisableSubscription { address } => {
3252- let changeset = ListSubscriptionChangeset {
3253- list: list.pk,
3254- address,
3255- account: None,
3256- name: None,
3257- digest: None,
3258- enabled: Some(false),
3259- verified: None,
3260- hide_address: None,
3261- receive_duplicates: None,
3262- receive_own_posts: None,
3263- receive_confirmation: None,
3264- };
3265- db.update_subscription(changeset)?;
3266- }
3267- Update {
3268- name,
3269- id,
3270- address,
3271- description,
3272- archive_url,
3273- owner_local_part,
3274- request_local_part,
3275- verify,
3276- hidden,
3277- enabled,
3278- } => {
3279- let description = string_opts!(description);
3280- let archive_url = string_opts!(archive_url);
3281- let owner_local_part = string_opts!(owner_local_part);
3282- let request_local_part = string_opts!(request_local_part);
3283- let changeset = MailingListChangeset {
3284- pk: list.pk,
3285- name,
3286- id,
3287- address,
3288- description,
3289- archive_url,
3290- owner_local_part,
3291- request_local_part,
3292- verify,
3293- hidden,
3294- enabled,
3295- };
3296- db.update_list(changeset)?;
3297- }
3298- ImportMembers {
3299- url,
3300- username,
3301- password,
3302- list_id,
3303- dry_run,
3304- skip_owners,
3305- } => {
3306- let conn = import::Mailman3Connection::new(&url, &username, &password).unwrap();
3307- if dry_run {
3308- let entries = conn.users(&list_id).unwrap();
3309- println!("{} result(s)", entries.len());
3310- for e in entries {
3311- println!(
3312- "{}{}<{}>",
3313- if let Some(n) = e.display_name() {
3314- n
3315- } else {
3316- ""
3317- },
3318- if e.display_name().is_none() { "" } else { " " },
3319- e.email()
3320- );
3321- }
3322- if !skip_owners {
3323- let entries = conn.owners(&list_id).unwrap();
3324- println!("\nOwners: {} result(s)", entries.len());
3325- for e in entries {
3326- println!(
3327- "{}{}<{}>",
3328- if let Some(n) = e.display_name() {
3329- n
3330- } else {
3331- ""
3332- },
3333- if e.display_name().is_none() { "" } else { " " },
3334- e.email()
3335- );
3336- }
3337- }
3338- } else {
3339- let entries = conn.users(&list_id).unwrap();
3340- let tx = db.transaction(Default::default()).unwrap();
3341- for sub in entries.into_iter().map(|e| e.into_subscription(list.pk)) {
3342- tx.add_subscription(list.pk, sub)?;
3343- }
3344- if !skip_owners {
3345- let entries = conn.owners(&list_id).unwrap();
3346- for sub in entries.into_iter().map(|e| e.into_owner(list.pk)) {
3347- tx.add_list_owner(sub)?;
3348- }
3349- }
3350- tx.commit()?;
3351- }
3352- }
3353- SubscriptionRequests => {
3354- let subscriptions = db.list_subscription_requests(list.pk)?;
3355- if subscriptions.is_empty() {
3356- println!("No subscription requests found.");
3357- } else {
3358- println!("Subscription requests of list {}", list.id);
3359- for l in subscriptions {
3360- println!("- {}", &l);
3361- }
3362- }
3363- }
3364- AcceptSubscriptionRequest {
3365- pk,
3366- do_not_send_confirmation,
3367- } => match db.accept_candidate_subscription(pk) {
3368- Ok(subscription) => {
3369- println!("Added: {subscription:#?}");
3370- if !do_not_send_confirmation {
3371- if let Err(err) = db
3372- .list(subscription.list)
3373- .and_then(|v| match v {
3374- Some(v) => Ok(v),
3375- None => Err(format!(
3376- "No list with id or pk {} was found",
3377- subscription.list
3378- )
3379- .into()),
3380- })
3381- .and_then(|list| {
3382- db.send_subscription_confirmation(&list, &subscription.address())
3383- })
3384- {
3385- eprintln!("Could not send subscription confirmation!");
3386- return Err(err);
3387- }
3388- println!("Sent confirmation e-mail to {}", subscription.address());
3389- } else {
3390- println!(
3391- "Did not sent confirmation e-mail to {}. You can do it manually with the \
3392- appropriate command.",
3393- subscription.address()
3394- );
3395- }
3396- }
3397- Err(err) => {
3398- eprintln!("Could not accept subscription request!");
3399- return Err(err);
3400- }
3401- },
3402- SendConfirmationForSubscription { pk } => {
3403- let req = match db.candidate_subscription(pk) {
3404- Ok(req) => req,
3405- Err(err) => {
3406- eprintln!("Could not find subscription request by that pk!");
3407-
3408- return Err(err);
3409- }
3410- };
3411- log::info!("Found {:#?}", req);
3412- if req.accepted.is_none() {
3413- return Err("Request has not been accepted!".into());
3414- }
3415- if let Err(err) = db
3416- .list(req.list)
3417- .and_then(|v| match v {
3418- Some(v) => Ok(v),
3419- None => Err(format!("No list with id or pk {} was found", req.list).into()),
3420- })
3421- .and_then(|list| db.send_subscription_confirmation(&list, &req.address()))
3422- {
3423- eprintln!("Could not send subscription request confirmation!");
3424- return Err(err);
3425- }
3426-
3427- println!("Sent confirmation e-mail to {}", req.address());
3428- }
3429- }
3430- Ok(())
3431- }
3432-
3433- pub fn create_list(
3434- db: &mut Connection,
3435- name: String,
3436- id: String,
3437- address: String,
3438- description: Option<String>,
3439- archive_url: Option<String>,
3440- quiet: bool,
3441- ) -> Result<()> {
3442- let new = db.create_list(MailingList {
3443- pk: 0,
3444- name,
3445- id,
3446- description,
3447- topics: vec![],
3448- address,
3449- archive_url,
3450- })?;
3451- log::trace!("created new list {:#?}", new);
3452- if !quiet {
3453- println!(
3454- "Created new list {:?} with primary key {}",
3455- new.id,
3456- new.pk()
3457- );
3458- }
3459- Ok(())
3460- }
3461-
3462- pub fn post(db: &mut Connection, dry_run: bool, debug: bool) -> Result<()> {
3463- if debug {
3464- println!("Post dry_run = {:?}", dry_run);
3465- }
3466-
3467- let tx = db
3468- .transaction(TransactionBehavior::Exclusive)
3469- .context("Could not open Exclusive transaction in database.")?;
3470- let mut input = String::new();
3471- std::io::stdin()
3472- .read_to_string(&mut input)
3473- .context("Could not read from stdin")?;
3474- match Envelope::from_bytes(input.as_bytes(), None) {
3475- Ok(env) => {
3476- if debug {
3477- eprintln!("Parsed envelope is:\n{:?}", &env);
3478- }
3479- tx.post(&env, input.as_bytes(), dry_run)?;
3480- }
3481- Err(err) if input.trim().is_empty() => {
3482- eprintln!("Empty input, abort.");
3483- return Err(err.into());
3484- }
3485- Err(err) => {
3486- eprintln!("Could not parse message: {}", err);
3487- let p = tx.conf().save_message(input)?;
3488- eprintln!("Message saved at {}", p.display());
3489- return Err(err.into());
3490- }
3491- }
3492- tx.commit()
3493- }
3494-
3495- pub fn flush_queue(db: &mut Connection, dry_run: bool, verbose: u8, debug: bool) -> Result<()> {
3496- let tx = db
3497- .transaction(TransactionBehavior::Exclusive)
3498- .context("Could not open Exclusive transaction in database.")?;
3499- let messages = tx.delete_from_queue(mailpot::queue::Queue::Out, vec![])?;
3500- if verbose > 0 || debug {
3501- println!("Queue out has {} messages.", messages.len());
3502- }
3503-
3504- let mut failures = Vec::with_capacity(messages.len());
3505-
3506- let send_mail = tx.conf().send_mail.clone();
3507- match send_mail {
3508- mailpot::SendMail::ShellCommand(cmd) => {
3509- fn submit(cmd: &str, msg: &QueueEntry, dry_run: bool) -> Result<()> {
3510- if dry_run {
3511- return Ok(());
3512- }
3513- let mut child = std::process::Command::new("sh")
3514- .arg("-c")
3515- .arg(cmd)
3516- .env("TO_ADDRESS", msg.to_addresses.clone())
3517- .stdout(Stdio::piped())
3518- .stdin(Stdio::piped())
3519- .stderr(Stdio::piped())
3520- .spawn()
3521- .context("sh command failed to start")?;
3522- let mut stdin = child
3523- .stdin
3524- .take()
3525- .ok_or_else(|| Error::from("Failed to open stdin"))?;
3526-
3527- let builder = std::thread::Builder::new();
3528-
3529- std::thread::scope(|s| {
3530- let handler = builder
3531- .spawn_scoped(s, move || {
3532- stdin
3533- .write_all(&msg.message)
3534- .expect("Failed to write to stdin");
3535- })
3536- .context(
3537- "Could not spawn IPC communication thread for SMTP ShellCommand \
3538- process",
3539- )?;
3540-
3541- handler.join().map_err(|_| {
3542- ErrorKind::External(mailpot::anyhow::anyhow!(
3543- "Could not join with IPC communication thread for SMTP ShellCommand \
3544- process"
3545- ))
3546- })?;
3547- let result = child.wait_with_output()?;
3548- if !result.status.success() {
3549- return Err(Error::new_external(format!(
3550- "{} proccess failed with exit code: {:?}\n{}",
3551- cmd,
3552- result.status.code(),
3553- String::from_utf8(result.stderr).unwrap()
3554- )));
3555- }
3556- Ok::<(), Error>(())
3557- })?;
3558- Ok(())
3559- }
3560- for msg in messages {
3561- if let Err(err) = submit(&cmd, &msg, dry_run) {
3562- if verbose > 0 || debug {
3563- eprintln!("Message {msg:?} failed with: {err}.");
3564- }
3565- failures.push((err, msg));
3566- } else if verbose > 0 || debug {
3567- eprintln!("Submitted message {}", msg.message_id);
3568- }
3569- }
3570- }
3571- mailpot::SendMail::Smtp(_) => {
3572- let conn_future = tx.new_smtp_connection()?;
3573- failures = smol::future::block_on(smol::spawn(async move {
3574- let mut conn = conn_future.await?;
3575- for msg in messages {
3576- if let Err(err) = Connection::submit(&mut conn, &msg, dry_run).await {
3577- failures.push((err, msg));
3578- }
3579- }
3580- Ok::<_, Error>(failures)
3581- }))?;
3582- }
3583- }
3584-
3585- for (err, mut msg) in failures {
3586- log::error!("Message {msg:?} failed with: {err}. Inserting to Deferred queue.");
3587-
3588- msg.queue = mailpot::queue::Queue::Deferred;
3589- tx.insert_to_queue(msg)?;
3590- }
3591-
3592- if !dry_run {
3593- tx.commit()?;
3594- }
3595- Ok(())
3596- }
3597-
3598- pub fn queue_(db: &mut Connection, queue: Queue, cmd: QueueCommand, quiet: bool) -> Result<()> {
3599- match cmd {
3600- QueueCommand::List => {
3601- let entries = db.queue(queue)?;
3602- if entries.is_empty() {
3603- if !quiet {
3604- println!("Queue {queue} is empty.");
3605- }
3606- } else {
3607- for e in entries {
3608- println!(
3609- "- {} {} {} {} {}",
3610- e.pk, e.datetime, e.from_address, e.to_addresses, e.subject
3611- );
3612- }
3613- }
3614- }
3615- QueueCommand::Print { index } => {
3616- let mut entries = db.queue(queue)?;
3617- if !index.is_empty() {
3618- entries.retain(|el| index.contains(&el.pk()));
3619- }
3620- if entries.is_empty() {
3621- if !quiet {
3622- println!("Queue {queue} is empty.");
3623- }
3624- } else {
3625- for e in entries {
3626- println!("{e:?}");
3627- }
3628- }
3629- }
3630- QueueCommand::Delete { index } => {
3631- let mut entries = db.queue(queue)?;
3632- if !index.is_empty() {
3633- entries.retain(|el| index.contains(&el.pk()));
3634- }
3635- if entries.is_empty() {
3636- if !quiet {
3637- println!("Queue {queue} is empty.");
3638- }
3639- } else {
3640- if !quiet {
3641- println!("Deleting queue {queue} elements {:?}", &index);
3642- }
3643- db.delete_from_queue(queue, index)?;
3644- if !quiet {
3645- for e in entries {
3646- println!("{e:?}");
3647- }
3648- }
3649- }
3650- }
3651- }
3652- Ok(())
3653- }
3654-
3655- pub fn import_maildir(
3656- db: &mut Connection,
3657- list_id: &str,
3658- mut maildir_path: PathBuf,
3659- quiet: bool,
3660- debug: bool,
3661- verbose: u8,
3662- ) -> Result<()> {
3663- let list = match list!(db, list_id) {
3664- Some(v) => v,
3665- None => {
3666- return Err(format!("No list with id or pk {} was found", list_id).into());
3667- }
3668- };
3669- if !maildir_path.is_absolute() {
3670- maildir_path = std::env::current_dir()
3671- .context("could not detect current directory")?
3672- .join(&maildir_path);
3673- }
3674-
3675- fn get_file_hash(file: &std::path::Path) -> EnvelopeHash {
3676- let mut hasher = DefaultHasher::default();
3677- file.hash(&mut hasher);
3678- EnvelopeHash(hasher.finish())
3679- }
3680- let mut buf = Vec::with_capacity(4096);
3681- let files = melib::maildir::MaildirType::list_mail_in_maildir_fs(maildir_path, true)
3682- .context("Could not parse files in maildir path")?;
3683- let mut ctr = 0;
3684- for file in files {
3685- let hash = get_file_hash(&file);
3686- let mut reader = std::io::BufReader::new(
3687- std::fs::File::open(&file)
3688- .with_context(|| format!("Could not open {}.", file.display()))?,
3689- );
3690- buf.clear();
3691- reader
3692- .read_to_end(&mut buf)
3693- .with_context(|| format!("Could not read from {}.", file.display()))?;
3694- match Envelope::from_bytes(buf.as_slice(), Some(file.flags())) {
3695- Ok(mut env) => {
3696- env.set_hash(hash);
3697- if verbose > 1 {
3698- println!(
3699- "Inserting post from {:?} with subject `{}` and Message-ID `{}`.",
3700- env.from(),
3701- env.subject(),
3702- env.message_id()
3703- );
3704- }
3705- db.insert_post(list.pk, &buf, &env).with_context(|| {
3706- format!(
3707- "Could not insert post `{}` from path `{}`",
3708- env.message_id(),
3709- file.display()
3710- )
3711- })?;
3712- ctr += 1;
3713- }
3714- Err(err) => {
3715- if verbose > 0 || debug {
3716- log::error!(
3717- "Could not parse Envelope from file {}: {err}",
3718- file.display()
3719- );
3720- }
3721- }
3722- }
3723- }
3724- if !quiet {
3725- println!("Inserted {} posts to {}.", ctr, list_id);
3726- }
3727- Ok(())
3728- }
3729-
3730- pub fn update_postfix_config(
3731- config_path: &Path,
3732- db: &mut Connection,
3733- master_cf: Option<PathBuf>,
3734- PostfixConfig {
3735- user,
3736- group,
3737- binary_path,
3738- process_limit,
3739- map_output_path,
3740- transport_name,
3741- }: PostfixConfig,
3742- ) -> Result<()> {
3743- let pfconf = mailpot::postfix::PostfixConfiguration {
3744- user: user.into(),
3745- group: group.map(Into::into),
3746- binary_path,
3747- process_limit,
3748- map_output_path,
3749- transport_name: transport_name.map(std::borrow::Cow::from),
3750- };
3751- pfconf
3752- .save_maps(db.conf())
3753- .context("Could not save maps.")?;
3754- pfconf
3755- .save_master_cf_entry(db.conf(), config_path, master_cf.as_deref())
3756- .context("Could not save master.cf file.")?;
3757-
3758- Ok(())
3759- }
3760-
3761- pub fn print_postfix_config(
3762- config_path: &Path,
3763- db: &mut Connection,
3764- PostfixConfig {
3765- user,
3766- group,
3767- binary_path,
3768- process_limit,
3769- map_output_path,
3770- transport_name,
3771- }: PostfixConfig,
3772- ) -> Result<()> {
3773- let pfconf = mailpot::postfix::PostfixConfiguration {
3774- user: user.into(),
3775- group: group.map(Into::into),
3776- binary_path,
3777- process_limit,
3778- map_output_path,
3779- transport_name: transport_name.map(std::borrow::Cow::from),
3780- };
3781- let lists = db.lists().context("Could not retrieve lists.")?;
3782- let lists_post_policies = lists
3783- .into_iter()
3784- .map(|l| {
3785- let pk = l.pk;
3786- Ok((
3787- l,
3788- db.list_post_policy(pk).with_context(|| {
3789- format!("Could not retrieve list post policy for list_pk = {pk}.")
3790- })?,
3791- ))
3792- })
3793- .collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
3794- let maps = pfconf.generate_maps(&lists_post_policies);
3795- let mastercf = pfconf.generate_master_cf_entry(db.conf(), config_path);
3796-
3797- println!("{maps}\n\n{mastercf}\n");
3798- Ok(())
3799- }
3800-
3801- pub fn accounts(db: &mut Connection, quiet: bool) -> Result<()> {
3802- let accounts = db.accounts()?;
3803- if accounts.is_empty() {
3804- if !quiet {
3805- println!("No accounts found.");
3806- }
3807- } else {
3808- for a in accounts {
3809- println!("- {:?}", a);
3810- }
3811- }
3812- Ok(())
3813- }
3814-
3815- pub fn account_info(db: &mut Connection, address: &str, quiet: bool) -> Result<()> {
3816- if let Some(acc) = db.account_by_address(address)? {
3817- let subs = db
3818- .account_subscriptions(acc.pk())
3819- .context("Could not retrieve account subscriptions for this account.")?;
3820- if subs.is_empty() {
3821- if !quiet {
3822- println!("No subscriptions found.");
3823- }
3824- } else {
3825- for s in subs {
3826- let list = db
3827- .list(s.list)
3828- .with_context(|| {
3829- format!(
3830- "Found subscription with list_pk = {} but could not retrieve the \
3831- list.\nListSubscription = {:?}",
3832- s.list, s
3833- )
3834- })?
3835- .ok_or_else(|| {
3836- format!(
3837- "Found subscription with list_pk = {} but no such list \
3838- exists.\nListSubscription = {:?}",
3839- s.list, s
3840- )
3841- })?;
3842- println!("- {:?} {}", s, list);
3843- }
3844- }
3845- } else {
3846- return Err(format!("Account with address {address} not found!").into());
3847- }
3848- Ok(())
3849- }
3850-
3851- pub fn add_account(
3852- db: &mut Connection,
3853- address: String,
3854- password: String,
3855- name: Option<String>,
3856- public_key: Option<String>,
3857- enabled: Option<bool>,
3858- ) -> Result<()> {
3859- db.add_account(Account {
3860- pk: 0,
3861- name,
3862- address,
3863- public_key,
3864- password,
3865- enabled: enabled.unwrap_or(true),
3866- })?;
3867- Ok(())
3868- }
3869-
3870- pub fn remove_account(db: &mut Connection, address: &str, quiet: bool) -> Result<()> {
3871- let mut input = String::new();
3872- if !quiet {
3873- loop {
3874- println!(
3875- "Are you sure you want to remove account with address {}? [Yy/n]",
3876- address
3877- );
3878- input.clear();
3879- std::io::stdin().read_line(&mut input)?;
3880- if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
3881- break;
3882- } else if input.trim() == "n" {
3883- return Ok(());
3884- }
3885- }
3886- }
3887-
3888- db.remove_account(address)?;
3889-
3890- Ok(())
3891- }
3892-
3893- pub fn update_account(
3894- db: &mut Connection,
3895- address: String,
3896- password: Option<String>,
3897- name: Option<Option<String>>,
3898- public_key: Option<Option<String>>,
3899- enabled: Option<Option<bool>>,
3900- ) -> Result<()> {
3901- let changeset = AccountChangeset {
3902- address,
3903- name,
3904- public_key,
3905- password,
3906- enabled,
3907- };
3908- db.update_account(changeset)?;
3909- Ok(())
3910- }
3911-
3912- pub fn repair(
3913- db: &mut Connection,
3914- fix: bool,
3915- all: bool,
3916- mut datetime_header_value: bool,
3917- mut remove_empty_accounts: bool,
3918- mut remove_accepted_subscription_requests: bool,
3919- mut warn_list_no_owner: bool,
3920- ) -> Result<()> {
3921- type LintFn = fn(&'_ mut mailpot::Connection, bool) -> std::result::Result<(), mailpot::Error>;
3922- let dry_run = !fix;
3923- if all {
3924- datetime_header_value = true;
3925- remove_empty_accounts = true;
3926- remove_accepted_subscription_requests = true;
3927- warn_list_no_owner = true;
3928- }
3929-
3930- if !(datetime_header_value
3931- | remove_empty_accounts
3932- | remove_accepted_subscription_requests
3933- | warn_list_no_owner)
3934- {
3935- return Err("No lints selected: specify them with flag arguments. See --help".into());
3936- }
3937-
3938- if dry_run {
3939- println!("running without making modifications (dry run)");
3940- }
3941-
3942- for (name, flag, lint_fn) in [
3943- (
3944- stringify!(datetime_header_value),
3945- datetime_header_value,
3946- datetime_header_value_lint as LintFn,
3947- ),
3948- (
3949- stringify!(remove_empty_accounts),
3950- remove_empty_accounts,
3951- remove_empty_accounts_lint as _,
3952- ),
3953- (
3954- stringify!(remove_accepted_subscription_requests),
3955- remove_accepted_subscription_requests,
3956- remove_accepted_subscription_requests_lint as _,
3957- ),
3958- (
3959- stringify!(warn_list_no_owner),
3960- warn_list_no_owner,
3961- warn_list_no_owner_lint as _,
3962- ),
3963- ] {
3964- if flag {
3965- lint_fn(db, dry_run).with_context(|| format!("Lint {name} failed."))?;
3966- }
3967- }
3968- Ok(())
3969- }
3970 diff --git a/cli/src/import.rs b/cli/src/import.rs
3971deleted file mode 100644
3972index f7425dd..0000000
3973--- a/cli/src/import.rs
3974+++ /dev/null
3975 @@ -1,149 +0,0 @@
3976- /*
3977- * This file is part of mailpot
3978- *
3979- * Copyright 2023 - Manos Pitsidianakis
3980- *
3981- * This program is free software: you can redistribute it and/or modify
3982- * it under the terms of the GNU Affero General Public License as
3983- * published by the Free Software Foundation, either version 3 of the
3984- * License, or (at your option) any later version.
3985- *
3986- * This program is distributed in the hope that it will be useful,
3987- * but WITHOUT ANY WARRANTY; without even the implied warranty of
3988- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
3989- * GNU Affero General Public License for more details.
3990- *
3991- * You should have received a copy of the GNU Affero General Public License
3992- * along with this program. If not, see <https://www.gnu.org/licenses/>.
3993- */
3994-
3995- use std::{borrow::Cow, time::Duration};
3996-
3997- use base64::{engine::general_purpose, Engine as _};
3998- use mailpot::models::{ListOwner, ListSubscription};
3999- use ureq::Agent;
4000-
4001- pub struct Mailman3Connection {
4002- agent: Agent,
4003- url: Cow<'static, str>,
4004- auth: String,
4005- }
4006-
4007- impl Mailman3Connection {
4008- pub fn new(
4009- url: &str,
4010- username: &str,
4011- password: &str,
4012- ) -> Result<Self, Box<dyn std::error::Error>> {
4013- let agent: Agent = ureq::AgentBuilder::new()
4014- .timeout_read(Duration::from_secs(5))
4015- .timeout_write(Duration::from_secs(5))
4016- .build();
4017- let mut buf = String::new();
4018- general_purpose::STANDARD
4019- .encode_string(format!("{username}:{password}").as_bytes(), &mut buf);
4020-
4021- let auth: String = format!("Basic {buf}");
4022-
4023- Ok(Self {
4024- agent,
4025- url: url.trim_end_matches('/').to_string().into(),
4026- auth,
4027- })
4028- }
4029-
4030- pub fn users(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
4031- let response: String = self
4032- .agent
4033- .get(&format!(
4034- "{}/lists/{list_address}/roster/member?fields=email&fields=display_name",
4035- self.url
4036- ))
4037- .set("Authorization", &self.auth)
4038- .call()?
4039- .into_string()?;
4040- Ok(serde_json::from_str::<Roster>(&response)?.entries)
4041- }
4042-
4043- pub fn owners(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
4044- let response: String = self
4045- .agent
4046- .get(&format!(
4047- "{}/lists/{list_address}/roster/owner?fields=email&fields=display_name",
4048- self.url
4049- ))
4050- .set("Authorization", &self.auth)
4051- .call()?
4052- .into_string()?;
4053- Ok(serde_json::from_str::<Roster>(&response)?.entries)
4054- }
4055- }
4056-
4057- #[derive(serde::Deserialize, Debug)]
4058- pub struct Roster {
4059- pub entries: Vec<Entry>,
4060- }
4061-
4062- #[derive(serde::Deserialize, Debug)]
4063- pub struct Entry {
4064- display_name: String,
4065- email: String,
4066- }
4067-
4068- impl Entry {
4069- pub fn display_name(&self) -> Option<&str> {
4070- if !self.display_name.trim().is_empty() && &self.display_name != "None" {
4071- Some(&self.display_name)
4072- } else {
4073- None
4074- }
4075- }
4076-
4077- pub fn email(&self) -> &str {
4078- &self.email
4079- }
4080-
4081- pub fn into_subscription(self, list: i64) -> ListSubscription {
4082- let Self {
4083- display_name,
4084- email,
4085- } = self;
4086-
4087- ListSubscription {
4088- pk: -1,
4089- list,
4090- address: email,
4091- name: if !display_name.trim().is_empty() && &display_name != "None" {
4092- Some(display_name)
4093- } else {
4094- None
4095- },
4096- account: None,
4097- enabled: true,
4098- verified: true,
4099- digest: false,
4100- hide_address: false,
4101- receive_duplicates: false,
4102- receive_own_posts: false,
4103- receive_confirmation: false,
4104- }
4105- }
4106-
4107- pub fn into_owner(self, list: i64) -> ListOwner {
4108- let Self {
4109- display_name,
4110- email,
4111- } = self;
4112-
4113- ListOwner {
4114- pk: -1,
4115- list,
4116- address: email,
4117- name: if !display_name.trim().is_empty() && &display_name != "None" {
4118- Some(display_name)
4119- } else {
4120- None
4121- },
4122- }
4123- }
4124- }
4125 diff --git a/cli/src/lib.rs b/cli/src/lib.rs
4126deleted file mode 100644
4127index 597fcbd..0000000
4128--- a/cli/src/lib.rs
4129+++ /dev/null
4130 @@ -1,29 +0,0 @@
4131- /*
4132- * This file is part of mailpot
4133- *
4134- * Copyright 2020 - Manos Pitsidianakis
4135- *
4136- * This program is free software: you can redistribute it and/or modify
4137- * it under the terms of the GNU Affero General Public License as
4138- * published by the Free Software Foundation, either version 3 of the
4139- * License, or (at your option) any later version.
4140- *
4141- * This program is distributed in the hope that it will be useful,
4142- * but WITHOUT ANY WARRANTY; without even the implied warranty of
4143- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
4144- * GNU Affero General Public License for more details.
4145- *
4146- * You should have received a copy of the GNU Affero General Public License
4147- * along with this program. If not, see <https://www.gnu.org/licenses/>.
4148- */
4149-
4150- extern crate base64;
4151- extern crate ureq;
4152- pub use std::path::PathBuf;
4153-
4154- mod args;
4155- pub mod commands;
4156- pub mod import;
4157- pub mod lints;
4158- pub use args::*;
4159- pub use clap::{Args, CommandFactory, Parser, Subcommand};
4160 diff --git a/cli/src/lints.rs b/cli/src/lints.rs
4161deleted file mode 100644
4162index 5d7fa01..0000000
4163--- a/cli/src/lints.rs
4164+++ /dev/null
4165 @@ -1,262 +0,0 @@
4166- /*
4167- * This file is part of mailpot
4168- *
4169- * Copyright 2020 - Manos Pitsidianakis
4170- *
4171- * This program is free software: you can redistribute it and/or modify
4172- * it under the terms of the GNU Affero General Public License as
4173- * published by the Free Software Foundation, either version 3 of the
4174- * License, or (at your option) any later version.
4175- *
4176- * This program is distributed in the hope that it will be useful,
4177- * but WITHOUT ANY WARRANTY; without even the implied warranty of
4178- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
4179- * GNU Affero General Public License for more details.
4180- *
4181- * You should have received a copy of the GNU Affero General Public License
4182- * along with this program. If not, see <https://www.gnu.org/licenses/>.
4183- */
4184-
4185- use mailpot::{
4186- chrono,
4187- melib::{self, Envelope},
4188- models::{Account, DbVal, ListSubscription, MailingList},
4189- rusqlite, Connection, Result,
4190- };
4191-
4192- pub fn datetime_header_value_lint(db: &mut Connection, dry_run: bool) -> Result<()> {
4193- let mut col = vec![];
4194- {
4195- let mut stmt = db.connection.prepare("SELECT * FROM post ORDER BY pk")?;
4196- let iter = stmt.query_map([], |row| {
4197- let pk: i64 = row.get("pk")?;
4198- let date_s: String = row.get("datetime")?;
4199- match melib::utils::datetime::rfc822_to_timestamp(date_s.trim()) {
4200- Err(_) | Ok(0) => {
4201- let mut timestamp: i64 = row.get("timestamp")?;
4202- let created: i64 = row.get("created")?;
4203- if timestamp == 0 {
4204- timestamp = created;
4205- }
4206- timestamp = std::cmp::min(timestamp, created);
4207- let timestamp = if timestamp <= 0 {
4208- None
4209- } else {
4210- // safe because we checked it's not negative or zero above.
4211- Some(timestamp as u64)
4212- };
4213- let message: Vec<u8> = row.get("message")?;
4214- Ok(Some((pk, date_s, message, timestamp)))
4215- }
4216- Ok(_) => Ok(None),
4217- }
4218- })?;
4219-
4220- for entry in iter {
4221- if let Some(s) = entry? {
4222- col.push(s);
4223- }
4224- }
4225- }
4226- let mut failures = 0;
4227- let tx = if dry_run {
4228- None
4229- } else {
4230- Some(db.connection.transaction()?)
4231- };
4232- if col.is_empty() {
4233- println!("datetime_header_value: ok");
4234- } else {
4235- println!("datetime_header_value: found {} entries", col.len());
4236- println!("pk\tDate value\tshould be");
4237- for (pk, val, message, timestamp) in col {
4238- let correct = if let Ok(v) =
4239- chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc3339(&val)
4240- {
4241- v.to_rfc2822()
4242- } else if let Some(v) = timestamp.map(|t| {
4243- melib::utils::datetime::timestamp_to_string(
4244- t,
4245- Some(melib::utils::datetime::formats::RFC822_DATE),
4246- true,
4247- )
4248- }) {
4249- v
4250- } else if let Ok(v) =
4251- Envelope::from_bytes(&message, None).map(|env| env.date_as_str().to_string())
4252- {
4253- v
4254- } else {
4255- failures += 1;
4256- println!("{pk}\t{val}\tCould not find any valid date value in the post metadata!");
4257- continue;
4258- };
4259- println!("{pk}\t{val}\t{correct}");
4260- if let Some(tx) = tx.as_ref() {
4261- tx.execute(
4262- "UPDATE post SET datetime = ? WHERE pk = ?",
4263- rusqlite::params![&correct, pk],
4264- )?;
4265- }
4266- }
4267- }
4268- if let Some(tx) = tx {
4269- tx.commit()?;
4270- }
4271- if failures > 0 {
4272- println!(
4273- "datetime_header_value: {failures} failure{}",
4274- if failures == 1 { "" } else { "s" }
4275- );
4276- }
4277- Ok(())
4278- }
4279-
4280- pub fn remove_empty_accounts_lint(db: &mut Connection, dry_run: bool) -> Result<()> {
4281- let mut col = vec![];
4282- {
4283- let mut stmt = db.connection.prepare(
4284- "SELECT * FROM account WHERE NOT EXISTS (SELECT 1 FROM subscription AS s WHERE \
4285- s.address = address) ORDER BY pk",
4286- )?;
4287- let iter = stmt.query_map([], |row| {
4288- let pk = row.get("pk")?;
4289- Ok(DbVal(
4290- Account {
4291- pk,
4292- name: row.get("name")?,
4293- address: row.get("address")?,
4294- public_key: row.get("public_key")?,
4295- password: row.get("password")?,
4296- enabled: row.get("enabled")?,
4297- },
4298- pk,
4299- ))
4300- })?;
4301-
4302- for entry in iter {
4303- let entry = entry?;
4304- col.push(entry);
4305- }
4306- }
4307- if col.is_empty() {
4308- println!("remove_empty_accounts: ok");
4309- } else {
4310- let tx = if dry_run {
4311- None
4312- } else {
4313- Some(db.connection.transaction()?)
4314- };
4315- println!("remove_empty_accounts: found {} entries", col.len());
4316- println!("pk\tAddress");
4317- for DbVal(Account { pk, address, .. }, _) in &col {
4318- println!("{pk}\t{address}");
4319- }
4320- if let Some(tx) = tx {
4321- for DbVal(_, pk) in col {
4322- tx.execute("DELETE FROM account WHERE pk = ?", [pk])?;
4323- }
4324- tx.commit()?;
4325- }
4326- }
4327- Ok(())
4328- }
4329-
4330- pub fn remove_accepted_subscription_requests_lint(
4331- db: &mut Connection,
4332- dry_run: bool,
4333- ) -> Result<()> {
4334- let mut col = vec![];
4335- {
4336- let mut stmt = db.connection.prepare(
4337- "SELECT * FROM candidate_subscription WHERE accepted IS NOT NULL ORDER BY pk",
4338- )?;
4339- let iter = stmt.query_map([], |row| {
4340- let pk = row.get("pk")?;
4341- Ok(DbVal(
4342- ListSubscription {
4343- pk,
4344- list: row.get("list")?,
4345- address: row.get("address")?,
4346- account: row.get("account")?,
4347- name: row.get("name")?,
4348- digest: row.get("digest")?,
4349- enabled: row.get("enabled")?,
4350- verified: row.get("verified")?,
4351- hide_address: row.get("hide_address")?,
4352- receive_duplicates: row.get("receive_duplicates")?,
4353- receive_own_posts: row.get("receive_own_posts")?,
4354- receive_confirmation: row.get("receive_confirmation")?,
4355- },
4356- pk,
4357- ))
4358- })?;
4359-
4360- for entry in iter {
4361- let entry = entry?;
4362- col.push(entry);
4363- }
4364- }
4365- if col.is_empty() {
4366- println!("remove_accepted_subscription_requests: ok");
4367- } else {
4368- let tx = if dry_run {
4369- None
4370- } else {
4371- Some(db.connection.transaction()?)
4372- };
4373- println!(
4374- "remove_accepted_subscription_requests: found {} entries",
4375- col.len()
4376- );
4377- println!("pk\tAddress");
4378- for DbVal(ListSubscription { pk, address, .. }, _) in &col {
4379- println!("{pk}\t{address}");
4380- }
4381- if let Some(tx) = tx {
4382- for DbVal(_, pk) in col {
4383- tx.execute("DELETE FROM candidate_subscription WHERE pk = ?", [pk])?;
4384- }
4385- tx.commit()?;
4386- }
4387- }
4388- Ok(())
4389- }
4390-
4391- pub fn warn_list_no_owner_lint(db: &mut Connection, _: bool) -> Result<()> {
4392- let mut stmt = db.connection.prepare(
4393- "SELECT * FROM list WHERE NOT EXISTS (SELECT 1 FROM owner AS o WHERE o.list = pk) ORDER \
4394- BY pk",
4395- )?;
4396- let iter = stmt.query_map([], |row| {
4397- let pk = row.get("pk")?;
4398- Ok(DbVal(
4399- MailingList {
4400- pk,
4401- name: row.get("name")?,
4402- id: row.get("id")?,
4403- address: row.get("address")?,
4404- description: row.get("description")?,
4405- topics: vec![],
4406- archive_url: row.get("archive_url")?,
4407- },
4408- pk,
4409- ))
4410- })?;
4411-
4412- let mut col = vec![];
4413- for entry in iter {
4414- let entry = entry?;
4415- col.push(entry);
4416- }
4417- if col.is_empty() {
4418- println!("warn_list_no_owner: ok");
4419- } else {
4420- println!("warn_list_no_owner: found {} entries", col.len());
4421- println!("pk\tName");
4422- for DbVal(MailingList { pk, name, .. }, _) in col {
4423- println!("{pk}\t{name}");
4424- }
4425- }
4426- Ok(())
4427- }
4428 diff --git a/cli/src/main.rs b/cli/src/main.rs
4429deleted file mode 100644
4430index 3b23746..0000000
4431--- a/cli/src/main.rs
4432+++ /dev/null
4433 @@ -1,221 +0,0 @@
4434- /*
4435- * This file is part of mailpot
4436- *
4437- * Copyright 2020 - Manos Pitsidianakis
4438- *
4439- * This program is free software: you can redistribute it and/or modify
4440- * it under the terms of the GNU Affero General Public License as
4441- * published by the Free Software Foundation, either version 3 of the
4442- * License, or (at your option) any later version.
4443- *
4444- * This program is distributed in the hope that it will be useful,
4445- * but WITHOUT ANY WARRANTY; without even the implied warranty of
4446- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
4447- * GNU Affero General Public License for more details.
4448- *
4449- * You should have received a copy of the GNU Affero General Public License
4450- * along with this program. If not, see <https://www.gnu.org/licenses/>.
4451- */
4452-
4453- use mailpot::{melib::smtp, Configuration, Connection, Context, Result};
4454- use mailpot_cli::{commands::*, *};
4455-
4456- fn run_app(
4457- config: Option<PathBuf>,
4458- cmd: Command,
4459- debug: bool,
4460- quiet: bool,
4461- verbose: u8,
4462- ) -> Result<()> {
4463- if let Command::SampleConfig { with_smtp } = cmd {
4464- let mut new = Configuration::new("/path/to/sqlite.db");
4465- new.administrators.push("admin@example.com".to_string());
4466- if with_smtp {
4467- new.send_mail = mailpot::SendMail::Smtp(smtp::SmtpServerConf {
4468- hostname: "mail.example.com".to_string(),
4469- port: 587,
4470- envelope_from: "".to_string(),
4471- auth: smtp::SmtpAuth::Auto {
4472- username: "user".to_string(),
4473- password: smtp::Password::Raw("hunter2".to_string()),
4474- auth_type: smtp::SmtpAuthType::default(),
4475- require_auth: true,
4476- },
4477- security: smtp::SmtpSecurity::StartTLS {
4478- danger_accept_invalid_certs: false,
4479- },
4480- extensions: Default::default(),
4481- });
4482- }
4483- println!("{}", new.to_toml());
4484- return Ok(());
4485- };
4486- let config_path = if let Some(path) = config.as_deref() {
4487- path
4488- } else {
4489- let mut opt = Opt::command();
4490- opt.error(
4491- clap::error::ErrorKind::MissingRequiredArgument,
4492- "--config is required for mailing list operations",
4493- )
4494- .exit();
4495- };
4496-
4497- let config = Configuration::from_file(config_path).with_context(|| {
4498- format!(
4499- "Could not read configuration file from path: {}",
4500- config_path.display()
4501- )
4502- })?;
4503-
4504- use Command::*;
4505- let mut db = Connection::open_or_create_db(config)
4506- .context("Could not open database connection with this configuration")?
4507- .trusted();
4508- match cmd {
4509- SampleConfig { .. } => {}
4510- DumpDatabase => {
4511- dump_database(&mut db).context("Could not dump database.")?;
4512- }
4513- ListLists => {
4514- list_lists(&mut db).context("Could not retrieve mailing lists.")?;
4515- }
4516- List { list_id, cmd } => {
4517- list(&mut db, &list_id, cmd, quiet).map_err(|err| {
4518- err.chain_err(|| {
4519- mailpot::Error::from(format!("Could not perform list command for {list_id}."))
4520- })
4521- })?;
4522- }
4523- CreateList {
4524- name,
4525- id,
4526- address,
4527- description,
4528- archive_url,
4529- } => {
4530- create_list(&mut db, name, id, address, description, archive_url, quiet)
4531- .context("Could not create list.")?;
4532- }
4533- Post { dry_run } => {
4534- post(&mut db, dry_run, debug).context("Could not process post.")?;
4535- }
4536- FlushQueue { dry_run } => {
4537- flush_queue(&mut db, dry_run, verbose, debug).with_context(|| {
4538- format!("Could not flush queue {}.", mailpot::queue::Queue::Out)
4539- })?;
4540- }
4541- Queue { queue, cmd } => {
4542- queue_(&mut db, queue, cmd, quiet)
4543- .with_context(|| format!("Could not perform queue command for queue `{queue}`."))?;
4544- }
4545- ImportMaildir {
4546- list_id,
4547- maildir_path,
4548- } => {
4549- import_maildir(
4550- &mut db,
4551- &list_id,
4552- maildir_path.clone(),
4553- quiet,
4554- debug,
4555- verbose,
4556- )
4557- .with_context(|| {
4558- format!(
4559- "Could not import maildir path {} to list `{list_id}`.",
4560- maildir_path.display(),
4561- )
4562- })?;
4563- }
4564- UpdatePostfixConfig { master_cf, config } => {
4565- update_postfix_config(config_path, &mut db, master_cf, config)
4566- .context("Could not update postfix configuration.")?;
4567- }
4568- PrintPostfixConfig { config } => {
4569- print_postfix_config(config_path, &mut db, config)
4570- .context("Could not print postfix configuration.")?;
4571- }
4572- Accounts => {
4573- accounts(&mut db, quiet).context("Could not retrieve accounts.")?;
4574- }
4575- AccountInfo { address } => {
4576- account_info(&mut db, &address, quiet).with_context(|| {
4577- format!("Could not retrieve account info for address {address}.")
4578- })?;
4579- }
4580- AddAccount {
4581- address,
4582- password,
4583- name,
4584- public_key,
4585- enabled,
4586- } => {
4587- add_account(&mut db, address, password, name, public_key, enabled)
4588- .context("Could not add account.")?;
4589- }
4590- RemoveAccount { address } => {
4591- remove_account(&mut db, &address, quiet)
4592- .with_context(|| format!("Could not remove account with address {address}."))?;
4593- }
4594- UpdateAccount {
4595- address,
4596- password,
4597- name,
4598- public_key,
4599- enabled,
4600- } => {
4601- update_account(&mut db, address, password, name, public_key, enabled)
4602- .context("Could not update account.")?;
4603- }
4604- Repair {
4605- fix,
4606- all,
4607- datetime_header_value,
4608- remove_empty_accounts,
4609- remove_accepted_subscription_requests,
4610- warn_list_no_owner,
4611- } => {
4612- repair(
4613- &mut db,
4614- fix,
4615- all,
4616- datetime_header_value,
4617- remove_empty_accounts,
4618- remove_accepted_subscription_requests,
4619- warn_list_no_owner,
4620- )
4621- .context("Could not perform database repair.")?;
4622- }
4623- }
4624-
4625- Ok(())
4626- }
4627-
4628- fn main() -> std::result::Result<(), i32> {
4629- let opt = Opt::parse();
4630- stderrlog::new()
4631- .module(module_path!())
4632- .module("mailpot")
4633- .quiet(opt.quiet)
4634- .verbosity(opt.verbose as usize)
4635- .timestamp(opt.ts.unwrap_or(stderrlog::Timestamp::Off))
4636- .init()
4637- .unwrap();
4638- if opt.debug {
4639- println!("DEBUG: {:?}", &opt);
4640- }
4641- let Opt {
4642- config,
4643- cmd,
4644- debug,
4645- quiet,
4646- verbose,
4647- ..
4648- } = opt;
4649- if let Err(err) = run_app(config, cmd, debug, quiet, verbose) {
4650- print!("{}", err.display_chain());
4651- std::process::exit(-1);
4652- }
4653- Ok(())
4654- }
4655 diff --git a/cli/tests/basic_interfaces.rs b/cli/tests/basic_interfaces.rs
4656deleted file mode 100644
4657index 8e8a438..0000000
4658--- a/cli/tests/basic_interfaces.rs
4659+++ /dev/null
4660 @@ -1,268 +0,0 @@
4661- /*
4662- * meli - email module
4663- *
4664- * Copyright 2019 Manos Pitsidianakis
4665- *
4666- * This file is part of meli.
4667- *
4668- * meli is free software: you can redistribute it and/or modify
4669- * it under the terms of the GNU General Public License as published by
4670- * the Free Software Foundation, either version 3 of the License, or
4671- * (at your option) any later version.
4672- *
4673- * meli is distributed in the hope that it will be useful,
4674- * but WITHOUT ANY WARRANTY; without even the implied warranty of
4675- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
4676- * GNU General Public License for more details.
4677- *
4678- * You should have received a copy of the GNU General Public License
4679- * along with meli. If not, see <http://www.gnu.org/licenses/>.
4680- */
4681-
4682- #![deny(dead_code)]
4683-
4684- use std::path::Path;
4685-
4686- use assert_cmd::{assert::OutputAssertExt, Command};
4687- use mailpot::{models::*, Configuration, Connection, SendMail};
4688- use predicates::prelude::*;
4689- use tempfile::TempDir;
4690-
4691- #[test]
4692- fn test_cli_basic_interfaces() {
4693- fn no_args() {
4694- let mut cmd = Command::cargo_bin("mpot").unwrap();
4695- // 2 -> incorrect usage
4696- cmd.assert().code(2);
4697- }
4698-
4699- fn version() {
4700- // --version is successful
4701- for arg in ["--version", "-V"] {
4702- let mut cmd = Command::cargo_bin("mpot").unwrap();
4703- let output = cmd.arg(arg).output().unwrap().assert();
4704- output.code(0).stdout(predicates::str::starts_with("mpot "));
4705- }
4706- }
4707-
4708- fn help() {
4709- // --help is successful
4710- for (arg, starts_with) in [
4711- ("--help", "GNU Affero version 3 or later"),
4712- ("-h", "mailing list manager"),
4713- ] {
4714- let mut cmd = Command::cargo_bin("mpot").unwrap();
4715- let output = cmd.arg(arg).output().unwrap().assert();
4716- output
4717- .code(0)
4718- .stdout(predicates::str::starts_with(starts_with))
4719- .stdout(predicates::str::contains("Usage:"));
4720- }
4721- }
4722-
4723- fn sample_config() {
4724- let mut cmd = Command::cargo_bin("mpot").unwrap();
4725- // sample-config does not require a configuration file as an argument (but other
4726- // commands do)
4727- let output = cmd.arg("sample-config").output().unwrap().assert();
4728- output.code(0).stdout(predicates::str::is_empty().not());
4729- }
4730-
4731- fn config_required() {
4732- let mut cmd = Command::cargo_bin("mpot").unwrap();
4733- let output = cmd.arg("list-lists").output().unwrap().assert();
4734- output.code(2).stdout(predicates::str::is_empty()).stderr(
4735- predicate::eq(
4736- r#"error: --config is required for mailing list operations
4737-
4738- Usage: mpot [OPTIONS] <COMMAND>
4739-
4740- For more information, try '--help'."#,
4741- )
4742- .trim()
4743- .normalize(),
4744- );
4745- }
4746-
4747- no_args();
4748- version();
4749- help();
4750- sample_config();
4751- config_required();
4752-
4753- let tmp_dir = TempDir::new().unwrap();
4754-
4755- let conf_path = tmp_dir.path().join("conf.toml");
4756- let db_path = tmp_dir.path().join("mpot.db");
4757-
4758- let config = Configuration {
4759- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
4760- db_path,
4761- data_path: tmp_dir.path().to_path_buf(),
4762- administrators: vec![],
4763- };
4764-
4765- let config_str = config.to_toml();
4766-
4767- fn config_not_exists(conf: &Path) {
4768- let mut cmd = Command::cargo_bin("mpot").unwrap();
4769- let output = cmd
4770- .arg("-c")
4771- .arg(conf)
4772- .arg("list-lists")
4773- .output()
4774- .unwrap()
4775- .assert();
4776- output.code(255).stderr(predicates::str::is_empty()).stdout(
4777- predicate::eq(
4778- format!(
4779- "[1] Could not read configuration file from path: {path} Caused by:\n[2] \
4780- Configuration file {path} not found. Caused by:\n[3] Error returned from \
4781- internal I/O operation: No such file or directory (os error 2)",
4782- path = conf.display()
4783- )
4784- .as_str(),
4785- )
4786- .trim()
4787- .normalize(),
4788- );
4789- }
4790-
4791- config_not_exists(&conf_path);
4792-
4793- std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
4794-
4795- fn list_lists(conf: &Path, eq: &str) {
4796- let mut cmd = Command::cargo_bin("mpot").unwrap();
4797- let output = cmd
4798- .arg("-c")
4799- .arg(conf)
4800- .arg("list-lists")
4801- .output()
4802- .unwrap()
4803- .assert();
4804- output
4805- .code(0)
4806- .stderr(predicates::str::is_empty())
4807- .stdout(predicate::eq(eq).trim().normalize());
4808- }
4809-
4810- list_lists(&conf_path, "No lists found.");
4811-
4812- {
4813- let db = Connection::open_or_create_db(config).unwrap().trusted();
4814-
4815- let foo_chat = db
4816- .create_list(MailingList {
4817- pk: 0,
4818- name: "foobar chat".into(),
4819- id: "foo-chat".into(),
4820- address: "foo-chat@example.com".into(),
4821- topics: vec![],
4822- description: None,
4823- archive_url: None,
4824- })
4825- .unwrap();
4826-
4827- assert_eq!(foo_chat.pk(), 1);
4828- }
4829- list_lists(
4830- &conf_path,
4831- "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
4832- \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
4833- owners: None\n\tPost policy: None\n\tSubscription policy: None",
4834- );
4835-
4836- fn create_list(conf: &Path) {
4837- let mut cmd = Command::cargo_bin("mpot").unwrap();
4838- let output = cmd
4839- .arg("-c")
4840- .arg(conf)
4841- .arg("create-list")
4842- .arg("--name")
4843- .arg("twobar")
4844- .arg("--id")
4845- .arg("twobar-chat")
4846- .arg("--address")
4847- .arg("twobar-chat@example.com")
4848- .output()
4849- .unwrap()
4850- .assert();
4851- output.code(0).stderr(predicates::str::is_empty()).stdout(
4852- predicate::eq("Created new list \"twobar-chat\" with primary key 2")
4853- .trim()
4854- .normalize(),
4855- );
4856- }
4857- create_list(&conf_path);
4858- list_lists(
4859- &conf_path,
4860- "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
4861- \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
4862- owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
4863- DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
4864- \"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
4865- 2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
4866- );
4867-
4868- fn add_list_owner(conf: &Path) {
4869- let mut cmd = Command::cargo_bin("mpot").unwrap();
4870- let output = cmd
4871- .arg("-c")
4872- .arg(conf)
4873- .arg("list")
4874- .arg("twobar-chat")
4875- .arg("add-list-owner")
4876- .arg("--address")
4877- .arg("list-owner@example.com")
4878- .output()
4879- .unwrap()
4880- .assert();
4881- output.code(0).stderr(predicates::str::is_empty()).stdout(
4882- predicate::eq("Added new list owner [#1 2] list-owner@example.com")
4883- .trim()
4884- .normalize(),
4885- );
4886- }
4887- add_list_owner(&conf_path);
4888- list_lists(
4889- &conf_path,
4890- "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
4891- \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
4892- owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
4893- DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
4894- \"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
4895- 2)\n\tList owners:\n\t- [#1 2] list-owner@example.com\n\tPost policy: \
4896- None\n\tSubscription policy: None",
4897- );
4898-
4899- fn remove_list_owner(conf: &Path) {
4900- let mut cmd = Command::cargo_bin("mpot").unwrap();
4901- let output = cmd
4902- .arg("-c")
4903- .arg(conf)
4904- .arg("list")
4905- .arg("twobar-chat")
4906- .arg("remove-list-owner")
4907- .arg("--pk")
4908- .arg("1")
4909- .output()
4910- .unwrap()
4911- .assert();
4912- output.code(0).stderr(predicates::str::is_empty()).stdout(
4913- predicate::eq("Removed list owner with pk = 1")
4914- .trim()
4915- .normalize(),
4916- );
4917- }
4918- remove_list_owner(&conf_path);
4919- list_lists(
4920- &conf_path,
4921- "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
4922- \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
4923- owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
4924- DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
4925- \"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
4926- 2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
4927- );
4928- }
4929 diff --git a/cli/tests/out_queue_flush.rs b/cli/tests/out_queue_flush.rs
4930deleted file mode 100644
4931index 5eb62b4..0000000
4932--- a/cli/tests/out_queue_flush.rs
4933+++ /dev/null
4934 @@ -1,398 +0,0 @@
4935- /*
4936- * meli - email module
4937- *
4938- * Copyright 2019 Manos Pitsidianakis
4939- *
4940- * This file is part of meli.
4941- *
4942- * meli is free software: you can redistribute it and/or modify
4943- * it under the terms of the GNU General Public License as published by
4944- * the Free Software Foundation, either version 3 of the License, or
4945- * (at your option) any later version.
4946- *
4947- * meli is distributed in the hope that it will be useful,
4948- * but WITHOUT ANY WARRANTY; without even the implied warranty of
4949- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
4950- * GNU General Public License for more details.
4951- *
4952- * You should have received a copy of the GNU General Public License
4953- * along with meli. If not, see <http://www.gnu.org/licenses/>.
4954- */
4955-
4956- use assert_cmd::assert::OutputAssertExt;
4957- use mailpot::{
4958- melib,
4959- models::{changesets::ListSubscriptionChangeset, *},
4960- queue::Queue,
4961- Configuration, Connection, SendMail,
4962- };
4963- use mailpot_tests::*;
4964- use predicates::prelude::*;
4965- use tempfile::TempDir;
4966-
4967- fn generate_mail(from: &str, to: &str, subject: &str, body: &str, seq: &mut usize) -> String {
4968- format!(
4969- "From: {from}@example.com
4970- To: <foo-chat{to}@example.com>
4971- Subject: {subject}
4972- Date: Thu, 29 Oct 2020 13:58:16 +0000
4973- Message-ID:
4974- <aaa{}@example.com>
4975- Content-Language: en-US
4976- Content-Type: text/plain
4977-
4978- {body}
4979- ",
4980- {
4981- let val = *seq;
4982- *seq += 1;
4983- val
4984- }
4985- )
4986- }
4987-
4988- #[test]
4989- fn test_out_queue_flush() {
4990- use assert_cmd::Command;
4991-
4992- let tmp_dir = TempDir::new().unwrap();
4993-
4994- let conf_path = tmp_dir.path().join("conf.toml");
4995- let db_path = tmp_dir.path().join("mpot.db");
4996- let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8826").build();
4997- let config = Configuration {
4998- send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
4999- db_path,
5000- data_path: tmp_dir.path().to_path_buf(),
5001- administrators: vec![],
5002- };
5003-
5004- let config_str = config.to_toml();
5005-
5006- std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
5007-
5008- log::info!("Creating foo-chat@example.com mailing list.");
5009- let post_policy;
5010- let foo_chat = {
5011- let db = Connection::open_or_create_db(config.clone())
5012- .unwrap()
5013- .trusted();
5014-
5015- let foo_chat = db
5016- .create_list(MailingList {
5017- pk: 0,
5018- name: "foobar chat".into(),
5019- id: "foo-chat".into(),
5020- address: "foo-chat@example.com".into(),
5021- description: None,
5022- topics: vec![],
5023- archive_url: None,
5024- })
5025- .unwrap();
5026-
5027- assert_eq!(foo_chat.pk(), 1);
5028- post_policy = db
5029- .set_list_post_policy(PostPolicy {
5030- pk: -1,
5031- list: foo_chat.pk(),
5032- announce_only: false,
5033- subscription_only: false,
5034- approval_needed: false,
5035- open: true,
5036- custom: false,
5037- })
5038- .unwrap();
5039- foo_chat
5040- };
5041-
5042- let headers_fn = |env: &melib::Envelope| {
5043- assert!(env.subject().starts_with(&format!("[{}] ", foo_chat.id)));
5044- let headers = env.other_headers();
5045-
5046- assert_eq!(
5047- headers
5048- .get(melib::HeaderName::LIST_ID)
5049- .map(|header| header.to_string()),
5050- Some(foo_chat.id_header())
5051- );
5052- assert_eq!(
5053- headers
5054- .get(melib::HeaderName::LIST_HELP)
5055- .map(|header| header.to_string()),
5056- foo_chat.help_header()
5057- );
5058- assert_eq!(
5059- headers
5060- .get(melib::HeaderName::LIST_POST)
5061- .map(|header| header.to_string()),
5062- foo_chat.post_header(Some(&post_policy))
5063- );
5064- };
5065-
5066- log::info!("Running mpot flush-queue on empty out queue.");
5067- let mut cmd = Command::cargo_bin("mpot").unwrap();
5068- let output = cmd
5069- .arg("-vv")
5070- .arg("-c")
5071- .arg(&conf_path)
5072- .arg("flush-queue")
5073- .output()
5074- .unwrap()
5075- .assert();
5076- output.code(0).stderr(predicates::str::is_empty()).stdout(
5077- predicate::eq("Queue out has 0 messages.")
5078- .trim()
5079- .normalize(),
5080- );
5081-
5082- let mut seq = 0; // for generated emails
5083- log::info!("Subscribe two users, Αλίκη and Χαραλάμπης to foo-chat.");
5084-
5085- {
5086- let db = Connection::open_or_create_db(config.clone())
5087- .unwrap()
5088- .trusted();
5089-
5090- for who in ["Αλίκη", "Χαραλάμπης"] {
5091- // = ["Alice", "Bob"]
5092- let mail = generate_mail(who, "+request", "subscribe", "", &mut seq);
5093- let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
5094- .expect("Could not parse message");
5095- db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
5096- .unwrap();
5097- }
5098- db.update_subscription(ListSubscriptionChangeset {
5099- list: foo_chat.pk(),
5100- address: "Χαραλάμπης@example.com".into(),
5101- receive_own_posts: Some(true),
5102- ..Default::default()
5103- })
5104- .unwrap();
5105- let out_queue = db.queue(Queue::Out).unwrap();
5106- assert_eq!(out_queue.len(), 2);
5107- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 2);
5108- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
5109- }
5110-
5111- log::info!("Flush out queue, subscription confirmations should be sent to the new users.");
5112- let mut cmd = Command::cargo_bin("mpot").unwrap();
5113- let output = cmd
5114- .arg("-vv")
5115- .arg("-c")
5116- .arg(&conf_path)
5117- .arg("flush-queue")
5118- .output()
5119- .unwrap()
5120- .assert();
5121- output.code(0).stdout(
5122- predicate::eq("Queue out has 2 messages.")
5123- .trim()
5124- .normalize(),
5125- );
5126-
5127- /* Check that confirmation emails are correct */
5128- let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
5129- assert_eq!(stored.len(), 2);
5130- assert_eq!(stored[0].0, "=?UTF-8?B?zpHOu86vzrrOtw==?=@example.com");
5131- assert_eq!(
5132- stored[1].0,
5133- "=?UTF-8?B?zqfOsc+BzrHOu86szrzPgM63z4I=?=@example.com"
5134- );
5135- for item in stored.iter() {
5136- assert_eq!(
5137- item.1.subject(),
5138- "[foo-chat] You have successfully subscribed to foobar chat."
5139- );
5140- assert_eq!(
5141- &item.1.field_from_to_string(),
5142- "foo-chat+request@example.com"
5143- );
5144- headers_fn(&item.1);
5145- }
5146-
5147- log::info!(
5148- "Χαραλάμπης submits a post to list. Flush out queue, Χαραλάμπης' post should be relayed \
5149- to Αλίκη, and Χαραλάμπης should receive a copy of their own post because of \
5150- `receive_own_posts` setting."
5151- );
5152-
5153- {
5154- let db = Connection::open_or_create_db(config.clone())
5155- .unwrap()
5156- .trusted();
5157- let mail = generate_mail("Χαραλάμπης", "", "hello world", "Hello there.", &mut seq);
5158- let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
5159- .expect("Could not parse message");
5160- db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
5161- .unwrap();
5162- let out_queue = db.queue(Queue::Out).unwrap();
5163- assert_eq!(out_queue.len(), 2);
5164- }
5165-
5166- let mut cmd = Command::cargo_bin("mpot").unwrap();
5167- let output = cmd
5168- .arg("-vv")
5169- .arg("-c")
5170- .arg(&conf_path)
5171- .arg("flush-queue")
5172- .output()
5173- .unwrap()
5174- .assert();
5175- output.code(0).stdout(
5176- predicate::eq("Queue out has 2 messages.")
5177- .trim()
5178- .normalize(),
5179- );
5180-
5181- /* Check that user posts are correct */
5182- {
5183- let db = Connection::open_or_create_db(config).unwrap().trusted();
5184-
5185- let out_queue = db.queue(Queue::Out).unwrap();
5186- assert_eq!(out_queue.len(), 0);
5187- }
5188-
5189- let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
5190- assert_eq!(stored.len(), 2);
5191- assert_eq!(stored[0].0, "Αλίκη@example.com");
5192- assert_eq!(stored[1].0, "Χαραλάμπης@example.com");
5193- assert_eq!(stored[0].1.message_id(), stored[1].1.message_id());
5194- assert_eq!(stored[0].1.other_headers(), stored[1].1.other_headers());
5195- headers_fn(&stored[0].1);
5196- }
5197-
5198- #[test]
5199- fn test_list_requests_submission() {
5200- use assert_cmd::Command;
5201-
5202- let tmp_dir = TempDir::new().unwrap();
5203-
5204- let conf_path = tmp_dir.path().join("conf.toml");
5205- let db_path = tmp_dir.path().join("mpot.db");
5206- let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8827").build();
5207- let config = Configuration {
5208- send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
5209- db_path,
5210- data_path: tmp_dir.path().to_path_buf(),
5211- administrators: vec![],
5212- };
5213-
5214- let config_str = config.to_toml();
5215-
5216- std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
5217-
5218- log::info!("Creating foo-chat@example.com mailing list.");
5219- let post_policy;
5220- let foo_chat = {
5221- let db = Connection::open_or_create_db(config.clone())
5222- .unwrap()
5223- .trusted();
5224-
5225- let foo_chat = db
5226- .create_list(MailingList {
5227- pk: 0,
5228- name: "foobar chat".into(),
5229- id: "foo-chat".into(),
5230- address: "foo-chat@example.com".into(),
5231- description: None,
5232- topics: vec![],
5233- archive_url: None,
5234- })
5235- .unwrap();
5236-
5237- assert_eq!(foo_chat.pk(), 1);
5238- post_policy = db
5239- .set_list_post_policy(PostPolicy {
5240- pk: -1,
5241- list: foo_chat.pk(),
5242- announce_only: false,
5243- subscription_only: false,
5244- approval_needed: false,
5245- open: true,
5246- custom: false,
5247- })
5248- .unwrap();
5249- foo_chat
5250- };
5251-
5252- let headers_fn = |env: &melib::Envelope| {
5253- let headers = env.other_headers();
5254-
5255- assert_eq!(
5256- headers.get(melib::HeaderName::LIST_ID),
5257- Some(foo_chat.id_header().as_str())
5258- );
5259- assert_eq!(
5260- headers
5261- .get(melib::HeaderName::LIST_HELP)
5262- .map(|header| header.to_string()),
5263- foo_chat.help_header()
5264- );
5265- assert_eq!(
5266- headers
5267- .get(melib::HeaderName::LIST_POST)
5268- .map(|header| header.to_string()),
5269- foo_chat.post_header(Some(&post_policy))
5270- );
5271- };
5272-
5273- log::info!("Running mpot flush-queue on empty out queue.");
5274- let mut cmd = Command::cargo_bin("mpot").unwrap();
5275- let output = cmd
5276- .arg("-vv")
5277- .arg("-c")
5278- .arg(&conf_path)
5279- .arg("flush-queue")
5280- .output()
5281- .unwrap()
5282- .assert();
5283- output.code(0).stderr(predicates::str::is_empty()).stdout(
5284- predicate::eq("Queue out has 0 messages.")
5285- .trim()
5286- .normalize(),
5287- );
5288-
5289- let mut seq = 0; // for generated emails
5290- log::info!("User Αλίκη sends to foo-chat+request with subject 'help'.");
5291-
5292- {
5293- let db = Connection::open_or_create_db(config).unwrap().trusted();
5294-
5295- let mail = generate_mail("Αλίκη", "+request", "help", "", &mut seq);
5296- let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
5297- .expect("Could not parse message");
5298- db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
5299- .unwrap();
5300- let out_queue = db.queue(Queue::Out).unwrap();
5301- assert_eq!(out_queue.len(), 1);
5302- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
5303- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
5304- }
5305-
5306- log::info!("Flush out queue, help reply should go to Αλίκη.");
5307- let mut cmd = Command::cargo_bin("mpot").unwrap();
5308- let output = cmd
5309- .arg("-vv")
5310- .arg("-c")
5311- .arg(&conf_path)
5312- .arg("flush-queue")
5313- .output()
5314- .unwrap()
5315- .assert();
5316- output.code(0).stdout(
5317- predicate::eq("Queue out has 1 messages.")
5318- .trim()
5319- .normalize(),
5320- );
5321-
5322- /* Check that help email is correct */
5323- let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
5324- assert_eq!(stored.len(), 1);
5325- assert_eq!(stored[0].0, "=?UTF-8?B?zpHOu86vzrrOtw==?=@example.com");
5326- assert_eq!(stored[0].1.subject(), "Help for foobar chat");
5327- assert_eq!(
5328- &stored[0].1.field_from_to_string(),
5329- "foo-chat+request@example.com"
5330- );
5331- headers_fn(&stored[0].1);
5332- }
5333 diff --git a/core/Cargo.toml b/core/Cargo.toml
5334deleted file mode 100644
5335index 7e995aa..0000000
5336--- a/core/Cargo.toml
5337+++ /dev/null
5338 @@ -1,35 +0,0 @@
5339- [package]
5340- name = "mailpot"
5341- version = "0.1.1"
5342- authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
5343- edition = "2021"
5344- license = "LICENSE"
5345- readme = "README.md"
5346- description = "mailing list manager"
5347- repository = "https://github.com/meli/mailpot"
5348- keywords = ["mail", "mailing-lists"]
5349- categories = ["email"]
5350-
5351- [lib]
5352- doc-scrape-examples = true
5353-
5354- [dependencies]
5355- anyhow = "1.0.58"
5356- chrono = { version = "^0.4", features = ["serde", ] }
5357- jsonschema = { version = "0.17", default-features = false }
5358- log = "0.4"
5359- melib = { default-features = false, features = ["mbox", "smtp", "unicode-algorithms", "maildir"], git = "https://git.meli-email.org/meli/meli.git", rev = "64e60cb" }
5360- minijinja = { version = "0.31.0", features = ["source", ] }
5361- percent-encoding = { version = "^2.1" }
5362- rusqlite = { version = "^0.30", features = ["bundled", "functions", "trace", "hooks", "serde_json", "array", "chrono", "unlock_notify"] }
5363- serde = { version = "^1", features = ["derive", ] }
5364- serde_json = "^1"
5365- thiserror = { version = "1.0.48", default-features = false }
5366- toml = "^0.5"
5367- xdg = "2.4.1"
5368-
5369- [dev-dependencies]
5370- mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
5371- reqwest = { version = "0.11", default-features = false, features = ["json", "blocking"] }
5372- stderrlog = { version = "^0.6" }
5373- tempfile = { version = "3.9" }
5374 diff --git a/core/README.md b/core/README.md
5375deleted file mode 100644
5376index 04d8dcf..0000000
5377--- a/core/README.md
5378+++ /dev/null
5379 @@ -1,17 +0,0 @@
5380- # mailpot-core
5381-
5382- Initialize `sqlite3` database
5383-
5384- ```shell
5385- sqlite3 mpot.db < ./src/schema.sql
5386- ```
5387-
5388- ## Tests
5389-
5390- `test_smtp_mailcrab` requires a running mailcrab instance.
5391- You must set the environment variable `MAILCRAB_IP` to run this.
5392- Example:
5393-
5394- ```shell
5395- MAILCRAB_IP="127.0.0.1" cargo test mailcrab
5396- ```
5397 diff --git a/core/build/make_migrations.rs b/core/build/make_migrations.rs
5398deleted file mode 100644
5399index 91f3f2e..0000000
5400--- a/core/build/make_migrations.rs
5401+++ /dev/null
5402 @@ -1,110 +0,0 @@
5403- /*
5404- * This file is part of mailpot
5405- *
5406- * Copyright 2023 - Manos Pitsidianakis
5407- *
5408- * This program is free software: you can redistribute it and/or modify
5409- * it under the terms of the GNU Affero General Public License as
5410- * published by the Free Software Foundation, either version 3 of the
5411- * License, or (at your option) any later version.
5412- *
5413- * This program is distributed in the hope that it will be useful,
5414- * but WITHOUT ANY WARRANTY; without even the implied warranty of
5415- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
5416- * GNU Affero General Public License for more details.
5417- *
5418- * You should have received a copy of the GNU Affero General Public License
5419- * along with this program. If not, see <https://www.gnu.org/licenses/>.
5420- */
5421-
5422- use std::{fs::read_dir, io::Write, path::Path};
5423-
5424- /// Scans migrations directory for file entries, and creates a rust file with an array containing
5425- /// the migration slices.
5426- ///
5427- ///
5428- /// If a migration is a data migration (not a CREATE, DROP or ALTER statement) it is appended to
5429- /// the schema file.
5430- ///
5431- /// Returns the current `user_version` PRAGMA value.
5432- pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
5433- migrations_path: M,
5434- output_file: O,
5435- schema_file: &mut Vec<u8>,
5436- ) -> i32 {
5437- let migrations_folder_path = migrations_path.as_ref();
5438- let output_file_path = output_file.as_ref();
5439-
5440- let mut paths = vec![];
5441- let mut undo_paths = vec![];
5442- for entry in read_dir(migrations_folder_path).unwrap() {
5443- let entry = entry.unwrap();
5444- let path = entry.path();
5445- if path.is_dir() || path.extension().map(|os| os.to_str().unwrap()) != Some("sql") {
5446- continue;
5447- }
5448- if path
5449- .file_name()
5450- .unwrap()
5451- .to_str()
5452- .unwrap()
5453- .ends_with("undo.sql")
5454- {
5455- undo_paths.push(path);
5456- } else {
5457- paths.push(path);
5458- }
5459- }
5460-
5461- paths.sort();
5462- undo_paths.sort();
5463- let mut migr_rs = OpenOptions::new()
5464- .write(true)
5465- .create(true)
5466- .truncate(true)
5467- .open(output_file_path)
5468- .unwrap();
5469- migr_rs
5470- .write_all(b"\n//(user_version, redo sql, undo sql\n&[")
5471- .unwrap();
5472- for (i, (p, u)) in paths.iter().zip(undo_paths.iter()).enumerate() {
5473- // This should be a number string, padded with 2 zeros if it's less than 3
5474- // digits. e.g. 001, \d{3}
5475- let mut num = p.file_stem().unwrap().to_str().unwrap();
5476- let is_data = num.ends_with(".data");
5477- if is_data {
5478- num = num.strip_suffix(".data").unwrap();
5479- }
5480-
5481- if !u.file_name().unwrap().to_str().unwrap().starts_with(num) {
5482- panic!("Undo file {u:?} should match with {p:?}");
5483- }
5484-
5485- if num.parse::<u32>().is_err() {
5486- panic!("Migration file {p:?} should start with a number");
5487- }
5488- assert_eq!(num.parse::<usize>().unwrap(), i + 1, "migration sql files should start with 1, not zero, and no intermediate numbers should be missing. Panicked on file: {}", p.display());
5489- migr_rs.write_all(b"(").unwrap();
5490- migr_rs
5491- .write_all(num.trim_start_matches('0').as_bytes())
5492- .unwrap();
5493- migr_rs.write_all(b",r##\"").unwrap();
5494-
5495- let redo = std::fs::read_to_string(p).unwrap();
5496- migr_rs.write_all(redo.trim().as_bytes()).unwrap();
5497- migr_rs.write_all(b"\"##,r##\"").unwrap();
5498- migr_rs
5499- .write_all(std::fs::read_to_string(u).unwrap().trim().as_bytes())
5500- .unwrap();
5501- migr_rs.write_all(b"\"##),").unwrap();
5502- if is_data {
5503- schema_file.extend(b"\n\n-- ".iter());
5504- schema_file.extend(num.as_bytes().iter());
5505- schema_file.extend(b".data.sql\n\n".iter());
5506- schema_file.extend(redo.into_bytes().into_iter());
5507- }
5508- }
5509- migr_rs.write_all(b"]").unwrap();
5510- migr_rs.flush().unwrap();
5511- paths.len() as i32
5512- }
5513 diff --git a/core/build/mod.rs b/core/build/mod.rs
5514deleted file mode 100644
5515index 44e41d2..0000000
5516--- a/core/build/mod.rs
5517+++ /dev/null
5518 @@ -1,95 +0,0 @@
5519- /*
5520- * This file is part of mailpot
5521- *
5522- * Copyright 2020 - Manos Pitsidianakis
5523- *
5524- * This program is free software: you can redistribute it and/or modify
5525- * it under the terms of the GNU Affero General Public License as
5526- * published by the Free Software Foundation, either version 3 of the
5527- * License, or (at your option) any later version.
5528- *
5529- * This program is distributed in the hope that it will be useful,
5530- * but WITHOUT ANY WARRANTY; without even the implied warranty of
5531- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
5532- * GNU Affero General Public License for more details.
5533- *
5534- * You should have received a copy of the GNU Affero General Public License
5535- * along with this program. If not, see <https://www.gnu.org/licenses/>.
5536- */
5537-
5538- use std::{
5539- fs::OpenOptions,
5540- process::{Command, Stdio},
5541- };
5542-
5543- // // Source: https://stackoverflow.com/a/64535181
5544- // fn is_output_file_outdated<P1, P2>(input: P1, output: P2) -> io::Result<bool>
5545- // where
5546- // P1: AsRef<Path>,
5547- // P2: AsRef<Path>,
5548- // {
5549- // let out_meta = metadata(output);
5550- // if let Ok(meta) = out_meta {
5551- // let output_mtime = meta.modified()?;
5552- //
5553- // // if input file is more recent than our output, we are outdated
5554- // let input_meta = metadata(input)?;
5555- // let input_mtime = input_meta.modified()?;
5556- //
5557- // Ok(input_mtime > output_mtime)
5558- // } else {
5559- // // output file not found, we are outdated
5560- // Ok(true)
5561- // }
5562- // }
5563-
5564- include!("make_migrations.rs");
5565-
5566- const MIGRATION_RS: &str = "src/migrations.rs.inc";
5567-
5568- fn main() {
5569- println!("cargo:rerun-if-changed=src/migrations.rs.inc");
5570- println!("cargo:rerun-if-changed=migrations");
5571- println!("cargo:rerun-if-changed=src/schema.sql.m4");
5572-
5573- let mut output = Command::new("m4")
5574- .arg("./src/schema.sql.m4")
5575- .output()
5576- .unwrap();
5577- if String::from_utf8_lossy(&output.stdout).trim().is_empty() {
5578- panic!(
5579- "m4 output is empty. stderr was {}",
5580- String::from_utf8_lossy(&output.stderr)
5581- );
5582- }
5583- let user_version: i32 = make_migrations("migrations", MIGRATION_RS, &mut output.stdout);
5584- let mut verify = Command::new(std::env::var("SQLITE_BIN").unwrap_or("sqlite3".into()))
5585- .stdin(Stdio::piped())
5586- .stdout(Stdio::piped())
5587- .stderr(Stdio::piped())
5588- .spawn()
5589- .unwrap();
5590- println!(
5591- "Verifying by creating an in-memory database in sqlite3 and feeding it the output schema."
5592- );
5593- verify
5594- .stdin
5595- .take()
5596- .unwrap()
5597- .write_all(&output.stdout)
5598- .unwrap();
5599- let exit = verify.wait_with_output().unwrap();
5600- if !exit.status.success() {
5601- panic!(
5602- "sqlite3 could not read SQL schema: {}",
5603- String::from_utf8_lossy(&exit.stdout)
5604- );
5605- }
5606- let mut file = std::fs::File::create("./src/schema.sql").unwrap();
5607- file.write_all(&output.stdout).unwrap();
5608- file.write_all(
5609- &format!("\n\n-- Set current schema version.\n\nPRAGMA user_version = {user_version};\n")
5610- .as_bytes(),
5611- )
5612- .unwrap();
5613- }
5614 diff --git a/core/create_migration.py b/core/create_migration.py
5615deleted file mode 100644
5616index a4b3318..0000000
5617--- a/core/create_migration.py
5618+++ /dev/null
5619 @@ -1,87 +0,0 @@
5620- import json
5621- from pathlib import Path
5622- import re
5623- import sys
5624- import pprint
5625- import argparse
5626-
5627-
5628- def make_undo(id: str) -> str:
5629- return f"DELETE FROM settings_json_schema WHERE id = '{id}';"
5630-
5631-
5632- def make_redo(id: str, value: str) -> str:
5633- return f"""INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('{id}', '{value}');"""
5634-
5635-
5636- class Migration:
5637- patt = re.compile(r"(\d+)[.].*sql")
5638-
5639- def __init__(self, path: Path):
5640- name = path.name
5641- self.path = path
5642- self.is_data = "data" in name
5643- self.is_undo = "undo" in name
5644- m = self.patt.match(name)
5645- self.seq = int(m.group(1))
5646- self.name = name
5647-
5648- def __str__(self) -> str:
5649- return str(self.seq)
5650-
5651- def __repr__(self) -> str:
5652- return f"Migration(seq={self.seq},name={self.name},path={self.path},is_data={self.is_data},is_undo={self.is_undo})"
5653-
5654-
5655- if __name__ == "__main__":
5656- parser = argparse.ArgumentParser(
5657- prog="Create migrations", description="", epilog=""
5658- )
5659- parser.add_argument("--data", action="store_true")
5660- parser.add_argument("--settings", action="store_true")
5661- parser.add_argument("--name", type=str, default=None)
5662- parser.add_argument("--dry-run", action="store_true")
5663- args = parser.parse_args()
5664- migrations = {}
5665- last = -1
5666- for f in Path(".").glob("migrations/*.sql"):
5667- m = Migration(f)
5668- last = max(last, m.seq)
5669- seq = str(m)
5670- if seq not in migrations:
5671- if m.is_undo:
5672- migrations[seq] = (None, m)
5673- else:
5674- migrations[seq] = (m, None)
5675- else:
5676- if m.is_undo:
5677- redo, _ = migrations[seq]
5678- migrations[seq] = (redo, m)
5679- else:
5680- _, undo = migrations[seq]
5681- migrations[seq] = (m, undo)
5682- # pprint.pprint(migrations)
5683- if args.data:
5684- data = ".data"
5685- else:
5686- data = ""
5687- new_name = f"{last+1:0>3}{data}.sql"
5688- new_undo_name = f"{last+1:0>3}{data}.undo.sql"
5689- if not args.dry_run:
5690- redo = ""
5691- undo = ""
5692- if args.settings:
5693- if not args.name:
5694- print("Please define a --name.")
5695- sys.exit(1)
5696- redo = make_redo(args.name, "{}")
5697- undo = make_undo(args.name)
5698- name = args.name.lower() + ".json"
5699- with open(Path("settings_json_schemas") / name, "x") as file:
5700- file.write("{}")
5701- with open(Path("migrations") / new_name, "x") as file, open(
5702- Path("migrations") / new_undo_name, "x"
5703- ) as undo_file:
5704- file.write(redo)
5705- undo_file.write(undo)
5706- print(f"Created to {new_name} and {new_undo_name}.")
5707 diff --git a/core/migrations/001.sql b/core/migrations/001.sql
5708deleted file mode 100644
5709index 345a376..0000000
5710--- a/core/migrations/001.sql
5711+++ /dev/null
5712 @@ -1,2 +0,0 @@
5713- PRAGMA foreign_keys=ON;
5714- ALTER TABLE templates RENAME TO template;
5715 diff --git a/core/migrations/001.undo.sql b/core/migrations/001.undo.sql
5716deleted file mode 100644
5717index e0e03fb..0000000
5718--- a/core/migrations/001.undo.sql
5719+++ /dev/null
5720 @@ -1,2 +0,0 @@
5721- PRAGMA foreign_keys=ON;
5722- ALTER TABLE template RENAME TO templates;
5723 diff --git a/core/migrations/002.sql b/core/migrations/002.sql
5724deleted file mode 100644
5725index 7dbb83a..0000000
5726--- a/core/migrations/002.sql
5727+++ /dev/null
5728 @@ -1,2 +0,0 @@
5729- PRAGMA foreign_keys=ON;
5730- ALTER TABLE list ADD COLUMN topics JSON NOT NULL CHECK (json_type(topics) == 'array') DEFAULT '[]';
5731 diff --git a/core/migrations/002.undo.sql b/core/migrations/002.undo.sql
5732deleted file mode 100644
5733index 9a18755..0000000
5734--- a/core/migrations/002.undo.sql
5735+++ /dev/null
5736 @@ -1,2 +0,0 @@
5737- PRAGMA foreign_keys=ON;
5738- ALTER TABLE list DROP COLUMN topics;
5739 diff --git a/core/migrations/003.sql b/core/migrations/003.sql
5740deleted file mode 100644
5741index 039c720..0000000
5742--- a/core/migrations/003.sql
5743+++ /dev/null
5744 @@ -1,20 +0,0 @@
5745- PRAGMA foreign_keys=ON;
5746-
5747- UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk;
5748-
5749- CREATE TRIGGER
5750- IF NOT EXISTS sort_topics_update_trigger
5751- AFTER UPDATE ON list
5752- FOR EACH ROW
5753- WHEN NEW.topics != OLD.topics
5754- BEGIN
5755- UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
5756- END;
5757-
5758- CREATE TRIGGER
5759- IF NOT EXISTS sort_topics_new_trigger
5760- AFTER INSERT ON list
5761- FOR EACH ROW
5762- BEGIN
5763- UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
5764- END;
5765 diff --git a/core/migrations/003.undo.sql b/core/migrations/003.undo.sql
5766deleted file mode 100644
5767index f6c7d9a..0000000
5768--- a/core/migrations/003.undo.sql
5769+++ /dev/null
5770 @@ -1,4 +0,0 @@
5771- PRAGMA foreign_keys=ON;
5772-
5773- DROP TRIGGER sort_topics_update_trigger;
5774- DROP TRIGGER sort_topics_new_trigger;
5775 diff --git a/core/migrations/004.sql b/core/migrations/004.sql
5776deleted file mode 100644
5777index 95aff47..0000000
5778--- a/core/migrations/004.sql
5779+++ /dev/null
5780 @@ -1,167 +0,0 @@
5781- CREATE TABLE IF NOT EXISTS settings_json_schema (
5782- pk INTEGER PRIMARY KEY NOT NULL,
5783- id TEXT NOT NULL UNIQUE,
5784- value JSON NOT NULL CHECK (json_type(value) = 'object'),
5785- created INTEGER NOT NULL DEFAULT (unixepoch()),
5786- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
5787- );
5788-
5789- CREATE TABLE IF NOT EXISTS list_settings_json (
5790- pk INTEGER PRIMARY KEY NOT NULL,
5791- name TEXT NOT NULL,
5792- list INTEGER,
5793- value JSON NOT NULL CHECK (json_type(value) = 'object'),
5794- is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
5795- created INTEGER NOT NULL DEFAULT (unixepoch()),
5796- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
5797- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
5798- FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
5799- UNIQUE (list, name) ON CONFLICT ROLLBACK
5800- );
5801-
5802- CREATE TRIGGER
5803- IF NOT EXISTS is_valid_settings_json_on_update
5804- AFTER UPDATE OF value, name, is_valid ON list_settings_json
5805- FOR EACH ROW
5806- BEGIN
5807- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
5808- UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
5809- END;
5810-
5811- CREATE TRIGGER
5812- IF NOT EXISTS is_valid_settings_json_on_insert
5813- AFTER INSERT ON list_settings_json
5814- FOR EACH ROW
5815- BEGIN
5816- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
5817- UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
5818- END;
5819-
5820- CREATE TRIGGER
5821- IF NOT EXISTS invalidate_settings_json_on_schema_update
5822- AFTER UPDATE OF value, id ON settings_json_schema
5823- FOR EACH ROW
5824- BEGIN
5825- UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
5826- END;
5827-
5828- DROP TRIGGER IF EXISTS last_modified_list;
5829- DROP TRIGGER IF EXISTS last_modified_owner;
5830- DROP TRIGGER IF EXISTS last_modified_post_policy;
5831- DROP TRIGGER IF EXISTS last_modified_subscription_policy;
5832- DROP TRIGGER IF EXISTS last_modified_subscription;
5833- DROP TRIGGER IF EXISTS last_modified_account;
5834- DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
5835- DROP TRIGGER IF EXISTS last_modified_template;
5836- DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
5837- DROP TRIGGER IF EXISTS last_modified_list_settings_json;
5838-
5839- -- [tag:last_modified_list]: update last_modified on every change.
5840- CREATE TRIGGER
5841- IF NOT EXISTS last_modified_list
5842- AFTER UPDATE ON list
5843- FOR EACH ROW
5844- WHEN NEW.last_modified == OLD.last_modified
5845- BEGIN
5846- UPDATE list SET last_modified = unixepoch()
5847- WHERE pk = NEW.pk;
5848- END;
5849-
5850- -- [tag:last_modified_owner]: update last_modified on every change.
5851- CREATE TRIGGER
5852- IF NOT EXISTS last_modified_owner
5853- AFTER UPDATE ON owner
5854- FOR EACH ROW
5855- WHEN NEW.last_modified == OLD.last_modified
5856- BEGIN
5857- UPDATE owner SET last_modified = unixepoch()
5858- WHERE pk = NEW.pk;
5859- END;
5860-
5861- -- [tag:last_modified_post_policy]: update last_modified on every change.
5862- CREATE TRIGGER
5863- IF NOT EXISTS last_modified_post_policy
5864- AFTER UPDATE ON post_policy
5865- FOR EACH ROW
5866- WHEN NEW.last_modified == OLD.last_modified
5867- BEGIN
5868- UPDATE post_policy SET last_modified = unixepoch()
5869- WHERE pk = NEW.pk;
5870- END;
5871-
5872- -- [tag:last_modified_subscription_policy]: update last_modified on every change.
5873- CREATE TRIGGER
5874- IF NOT EXISTS last_modified_subscription_policy
5875- AFTER UPDATE ON subscription_policy
5876- FOR EACH ROW
5877- WHEN NEW.last_modified == OLD.last_modified
5878- BEGIN
5879- UPDATE subscription_policy SET last_modified = unixepoch()
5880- WHERE pk = NEW.pk;
5881- END;
5882-
5883- -- [tag:last_modified_subscription]: update last_modified on every change.
5884- CREATE TRIGGER
5885- IF NOT EXISTS last_modified_subscription
5886- AFTER UPDATE ON subscription
5887- FOR EACH ROW
5888- WHEN NEW.last_modified == OLD.last_modified
5889- BEGIN
5890- UPDATE subscription SET last_modified = unixepoch()
5891- WHERE pk = NEW.pk;
5892- END;
5893-
5894- -- [tag:last_modified_account]: update last_modified on every change.
5895- CREATE TRIGGER
5896- IF NOT EXISTS last_modified_account
5897- AFTER UPDATE ON account
5898- FOR EACH ROW
5899- WHEN NEW.last_modified == OLD.last_modified
5900- BEGIN
5901- UPDATE account SET last_modified = unixepoch()
5902- WHERE pk = NEW.pk;
5903- END;
5904-
5905- -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
5906- CREATE TRIGGER
5907- IF NOT EXISTS last_modified_candidate_subscription
5908- AFTER UPDATE ON candidate_subscription
5909- FOR EACH ROW
5910- WHEN NEW.last_modified == OLD.last_modified
5911- BEGIN
5912- UPDATE candidate_subscription SET last_modified = unixepoch()
5913- WHERE pk = NEW.pk;
5914- END;
5915-
5916- -- [tag:last_modified_template]: update last_modified on every change.
5917- CREATE TRIGGER
5918- IF NOT EXISTS last_modified_template
5919- AFTER UPDATE ON template
5920- FOR EACH ROW
5921- WHEN NEW.last_modified == OLD.last_modified
5922- BEGIN
5923- UPDATE template SET last_modified = unixepoch()
5924- WHERE pk = NEW.pk;
5925- END;
5926-
5927- -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
5928- CREATE TRIGGER
5929- IF NOT EXISTS last_modified_settings_json_schema
5930- AFTER UPDATE ON settings_json_schema
5931- FOR EACH ROW
5932- WHEN NEW.last_modified == OLD.last_modified
5933- BEGIN
5934- UPDATE settings_json_schema SET last_modified = unixepoch()
5935- WHERE pk = NEW.pk;
5936- END;
5937-
5938- -- [tag:last_modified_list_settings_json]: update last_modified on every change.
5939- CREATE TRIGGER
5940- IF NOT EXISTS last_modified_list_settings_json
5941- AFTER UPDATE ON list_settings_json
5942- FOR EACH ROW
5943- WHEN NEW.last_modified == OLD.last_modified
5944- BEGIN
5945- UPDATE list_settings_json SET last_modified = unixepoch()
5946- WHERE pk = NEW.pk;
5947- END;
5948 diff --git a/core/migrations/004.undo.sql b/core/migrations/004.undo.sql
5949deleted file mode 100644
5950index b780b5c..0000000
5951--- a/core/migrations/004.undo.sql
5952+++ /dev/null
5953 @@ -1,2 +0,0 @@
5954- DROP TABLE settings_json_schema;
5955- DROP TABLE list_settings_json;
5956 diff --git a/core/migrations/005.data.sql b/core/migrations/005.data.sql
5957deleted file mode 100644
5958index af28922..0000000
5959--- a/core/migrations/005.data.sql
5960+++ /dev/null
5961 @@ -1,31 +0,0 @@
5962- INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
5963- "$schema": "http://json-schema.org/draft-07/schema",
5964- "$ref": "#/$defs/ArchivedAtLinkSettings",
5965- "$defs": {
5966- "ArchivedAtLinkSettings": {
5967- "title": "ArchivedAtLinkSettings",
5968- "description": "Settings for ArchivedAtLink message filter",
5969- "type": "object",
5970- "properties": {
5971- "template": {
5972- "title": "Jinja template for header value",
5973- "description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
5974- "examples": [
5975- "https://www.example.com/{{msg_id}}",
5976- "https://www.example.com/{{msg_id}}.html"
5977- ],
5978- "type": "string",
5979- "pattern": ".+[{][{]msg_id[}][}].*"
5980- },
5981- "preserve_carets": {
5982- "title": "Preserve carets of `Message-ID` in generated value",
5983- "type": "boolean",
5984- "default": false
5985- }
5986- },
5987- "required": [
5988- "template"
5989- ]
5990- }
5991- }
5992- }');
5993 diff --git a/core/migrations/005.data.undo.sql b/core/migrations/005.data.undo.sql
5994deleted file mode 100644
5995index 952d321..0000000
5996--- a/core/migrations/005.data.undo.sql
5997+++ /dev/null
5998 @@ -1 +0,0 @@
5999- DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';
6000 diff --git a/core/migrations/006.data.sql b/core/migrations/006.data.sql
6001deleted file mode 100644
6002index a5741e0..0000000
6003--- a/core/migrations/006.data.sql
6004+++ /dev/null
6005 @@ -1,20 +0,0 @@
6006- INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
6007- "$schema": "http://json-schema.org/draft-07/schema",
6008- "$ref": "#/$defs/AddSubjectTagPrefixSettings",
6009- "$defs": {
6010- "AddSubjectTagPrefixSettings": {
6011- "title": "AddSubjectTagPrefixSettings",
6012- "description": "Settings for AddSubjectTagPrefix message filter",
6013- "type": "object",
6014- "properties": {
6015- "enabled": {
6016- "title": "If true, the list subject prefix is added to post subjects.",
6017- "type": "boolean"
6018- }
6019- },
6020- "required": [
6021- "enabled"
6022- ]
6023- }
6024- }
6025- }');
6026 diff --git a/core/migrations/006.data.undo.sql b/core/migrations/006.data.undo.sql
6027deleted file mode 100644
6028index a805e53..0000000
6029--- a/core/migrations/006.data.undo.sql
6030+++ /dev/null
6031 @@ -1 +0,0 @@
6032- DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';
6033 diff --git a/core/migrations/007.data.sql b/core/migrations/007.data.sql
6034deleted file mode 100644
6035index c1bbfc2..0000000
6036--- a/core/migrations/007.data.sql
6037+++ /dev/null
6038 @@ -1,33 +0,0 @@
6039- INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
6040- "$schema": "http://json-schema.org/draft-07/schema",
6041- "$ref": "#/$defs/MimeRejectSettings",
6042- "$defs": {
6043- "MimeRejectSettings": {
6044- "title": "MimeRejectSettings",
6045- "description": "Settings for MimeReject message filter",
6046- "type": "object",
6047- "properties": {
6048- "enabled": {
6049- "title": "If true, list posts that contain mime types in the reject array are rejected.",
6050- "type": "boolean"
6051- },
6052- "reject": {
6053- "title": "Mime types to reject.",
6054- "type": "array",
6055- "minLength": 0,
6056- "items": { "$ref": "#/$defs/MimeType" }
6057- },
6058- "required": [
6059- "enabled"
6060- ]
6061- }
6062- },
6063- "MimeType": {
6064- "type": "string",
6065- "maxLength": 127,
6066- "minLength": 3,
6067- "uniqueItems": true,
6068- "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
6069- }
6070- }
6071- }');
6072 diff --git a/core/migrations/007.data.undo.sql b/core/migrations/007.data.undo.sql
6073deleted file mode 100644
6074index cfd0945..0000000
6075--- a/core/migrations/007.data.undo.sql
6076+++ /dev/null
6077 @@ -1 +0,0 @@
6078- DELETE FROM settings_json_schema WHERE id = 'MimeRejectSettings';
6079\ No newline at end of file
6080 diff --git a/core/rustfmt.toml b/core/rustfmt.toml
6081deleted file mode 120000
6082index 39f97b0..0000000
6083--- a/core/rustfmt.toml
6084+++ /dev/null
6085 @@ -1 +0,0 @@
6086- ../rustfmt.toml
6087\ No newline at end of file
6088 diff --git a/core/settings_json_schemas/addsubjecttagprefix.json b/core/settings_json_schemas/addsubjecttagprefix.json
6089deleted file mode 100644
6090index 4556b2b..0000000
6091--- a/core/settings_json_schemas/addsubjecttagprefix.json
6092+++ /dev/null
6093 @@ -1,20 +0,0 @@
6094- {
6095- "$schema": "http://json-schema.org/draft-07/schema",
6096- "$ref": "#/$defs/AddSubjectTagPrefixSettings",
6097- "$defs": {
6098- "AddSubjectTagPrefixSettings": {
6099- "title": "AddSubjectTagPrefixSettings",
6100- "description": "Settings for AddSubjectTagPrefix message filter",
6101- "type": "object",
6102- "properties": {
6103- "enabled": {
6104- "title": "If true, the list subject prefix is added to post subjects.",
6105- "type": "boolean"
6106- }
6107- },
6108- "required": [
6109- "enabled"
6110- ]
6111- }
6112- }
6113- }
6114 diff --git a/core/settings_json_schemas/archivedatlink.json b/core/settings_json_schemas/archivedatlink.json
6115deleted file mode 100644
6116index 2b832fe..0000000
6117--- a/core/settings_json_schemas/archivedatlink.json
6118+++ /dev/null
6119 @@ -1,31 +0,0 @@
6120- {
6121- "$schema": "http://json-schema.org/draft-07/schema",
6122- "$ref": "#/$defs/ArchivedAtLinkSettings",
6123- "$defs": {
6124- "ArchivedAtLinkSettings": {
6125- "title": "ArchivedAtLinkSettings",
6126- "description": "Settings for ArchivedAtLink message filter",
6127- "type": "object",
6128- "properties": {
6129- "template": {
6130- "title": "Jinja template for header value",
6131- "description": "Template for `Archived-At` header value, as described in RFC 5064 \"The Archived-At Message Header Field\". The template receives only one string variable with the value of the mailing list post `Message-ID` header.\n\nFor example, if:\n\n- the template is `http://www.example.com/mid/{{msg_id}}`\n- the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\nThe full header will be generated as:\n\n`Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\nNote: Surrounding carets in the `Message-ID` value are not required. If you wish to preserve them in the URL, set option `preserve-carets` to true.",
6132- "examples": [
6133- "https://www.example.com/{{msg_id}}",
6134- "https://www.example.com/{{msg_id}}.html"
6135- ],
6136- "type": "string",
6137- "pattern": ".+[{][{]msg_id[}][}].*"
6138- },
6139- "preserve_carets": {
6140- "title": "Preserve carets of `Message-ID` in generated value",
6141- "type": "boolean",
6142- "default": false
6143- }
6144- },
6145- "required": [
6146- "template"
6147- ]
6148- }
6149- }
6150- }
6151 diff --git a/core/settings_json_schemas/mimerejectsettings.json b/core/settings_json_schemas/mimerejectsettings.json
6152deleted file mode 100644
6153index 5bd0511..0000000
6154--- a/core/settings_json_schemas/mimerejectsettings.json
6155+++ /dev/null
6156 @@ -1,33 +0,0 @@
6157- {
6158- "$schema": "http://json-schema.org/draft-07/schema",
6159- "$ref": "#/$defs/MimeRejectSettings",
6160- "$defs": {
6161- "MimeRejectSettings": {
6162- "title": "MimeRejectSettings",
6163- "description": "Settings for MimeReject message filter",
6164- "type": "object",
6165- "properties": {
6166- "enabled": {
6167- "title": "If true, list posts that contain mime types in the reject array are rejected.",
6168- "type": "boolean"
6169- },
6170- "reject": {
6171- "title": "Mime types to reject.",
6172- "type": "array",
6173- "minLength": 0,
6174- "items": { "$ref": "#/$defs/MimeType" }
6175- },
6176- "required": [
6177- "enabled"
6178- ]
6179- }
6180- },
6181- "MimeType": {
6182- "type": "string",
6183- "maxLength": 127,
6184- "minLength": 3,
6185- "uniqueItems": true,
6186- "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
6187- }
6188- }
6189- }
6190 diff --git a/core/src/config.rs b/core/src/config.rs
6191deleted file mode 100644
6192index ef2ab16..0000000
6193--- a/core/src/config.rs
6194+++ /dev/null
6195 @@ -1,167 +0,0 @@
6196- /*
6197- * This file is part of mailpot
6198- *
6199- * Copyright 2020 - Manos Pitsidianakis
6200- *
6201- * This program is free software: you can redistribute it and/or modify
6202- * it under the terms of the GNU Affero General Public License as
6203- * published by the Free Software Foundation, either version 3 of the
6204- * License, or (at your option) any later version.
6205- *
6206- * This program is distributed in the hope that it will be useful,
6207- * but WITHOUT ANY WARRANTY; without even the implied warranty of
6208- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
6209- * GNU Affero General Public License for more details.
6210- *
6211- * You should have received a copy of the GNU Affero General Public License
6212- * along with this program. If not, see <https://www.gnu.org/licenses/>.
6213- */
6214-
6215- use std::{
6216- io::{Read, Write},
6217- os::unix::fs::PermissionsExt,
6218- path::{Path, PathBuf},
6219- };
6220-
6221- use chrono::prelude::*;
6222-
6223- use super::errors::*;
6224-
6225- /// How to send e-mail.
6226- #[derive(Debug, Serialize, Deserialize, Clone)]
6227- #[serde(tag = "type", content = "value")]
6228- pub enum SendMail {
6229- /// A `melib` configuration for talking to an SMTP server.
6230- Smtp(melib::smtp::SmtpServerConf),
6231- /// A plain shell command passed to `sh -c` with the e-mail passed in the
6232- /// stdin.
6233- ShellCommand(String),
6234- }
6235-
6236- /// The configuration for the mailpot database and the mail server.
6237- #[derive(Debug, Serialize, Deserialize, Clone)]
6238- pub struct Configuration {
6239- /// How to send e-mail.
6240- pub send_mail: SendMail,
6241- /// The location of the sqlite3 file.
6242- pub db_path: PathBuf,
6243- /// The directory where data are stored.
6244- pub data_path: PathBuf,
6245- /// Instance administrators (List of e-mail addresses). Optional.
6246- #[serde(default)]
6247- pub administrators: Vec<String>,
6248- }
6249-
6250- impl Configuration {
6251- /// Create a new configuration value from a given database path value.
6252- ///
6253- /// If you wish to create a new database with this configuration, use
6254- /// [`Connection::open_or_create_db`](crate::Connection::open_or_create_db).
6255- /// To open an existing database, use
6256- /// [`Database::open_db`](crate::Connection::open_db).
6257- pub fn new(db_path: impl Into<PathBuf>) -> Self {
6258- let db_path = db_path.into();
6259- Self {
6260- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
6261- data_path: db_path
6262- .parent()
6263- .map(Path::to_path_buf)
6264- .unwrap_or_else(|| db_path.clone()),
6265- administrators: vec![],
6266- db_path,
6267- }
6268- }
6269-
6270- /// Deserialize configuration from TOML file.
6271- pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
6272- let path = path.as_ref();
6273- let mut s = String::new();
6274- let mut file = std::fs::File::open(path)
6275- .with_context(|| format!("Configuration file {} not found.", path.display()))?;
6276- file.read_to_string(&mut s)
6277- .with_context(|| format!("Could not read from file {}.", path.display()))?;
6278- let config: Self = toml::from_str(&s)
6279- .map_err(anyhow::Error::from)
6280- .with_context(|| {
6281- format!(
6282- "Could not parse configuration file `{}` successfully: ",
6283- path.display()
6284- )
6285- })?;
6286-
6287- Ok(config)
6288- }
6289-
6290- /// The saved data path.
6291- pub fn data_directory(&self) -> &Path {
6292- self.data_path.as_path()
6293- }
6294-
6295- /// The sqlite3 database path.
6296- pub fn db_path(&self) -> &Path {
6297- self.db_path.as_path()
6298- }
6299-
6300- /// Save message to a custom path.
6301- pub fn save_message_to_path(&self, msg: &str, mut path: PathBuf) -> Result<PathBuf> {
6302- if path.is_dir() {
6303- let now = Local::now().timestamp();
6304- path.push(format!("{}-failed.eml", now));
6305- }
6306-
6307- debug_assert!(path != self.db_path());
6308- let mut file = std::fs::File::create(&path)
6309- .with_context(|| format!("Could not create file {}.", path.display()))?;
6310- let metadata = file
6311- .metadata()
6312- .with_context(|| format!("Could not fstat file {}.", path.display()))?;
6313- let mut permissions = metadata.permissions();
6314-
6315- permissions.set_mode(0o600); // Read/write for owner only.
6316- file.set_permissions(permissions)
6317- .with_context(|| format!("Could not chmod 600 file {}.", path.display()))?;
6318- file.write_all(msg.as_bytes())
6319- .with_context(|| format!("Could not write message to file {}.", path.display()))?;
6320- file.flush()
6321- .with_context(|| format!("Could not flush message I/O to file {}.", path.display()))?;
6322- Ok(path)
6323- }
6324-
6325- /// Save message to the data directory.
6326- pub fn save_message(&self, msg: String) -> Result<PathBuf> {
6327- self.save_message_to_path(&msg, self.data_directory().to_path_buf())
6328- }
6329-
6330- /// Serialize configuration to a TOML string.
6331- pub fn to_toml(&self) -> String {
6332- toml::Value::try_from(self)
6333- .expect("Could not serialize config to TOML")
6334- .to_string()
6335- }
6336- }
6337-
6338- #[cfg(test)]
6339- mod tests {
6340- use tempfile::TempDir;
6341-
6342- use super::*;
6343-
6344- #[test]
6345- fn test_config_parse_error() {
6346- let tmp_dir = TempDir::new().unwrap();
6347- let conf_path = tmp_dir.path().join("conf.toml");
6348- std::fs::write(&conf_path, b"afjsad skas as a as\n\n\n\n\t\x11\n").unwrap();
6349-
6350- assert_eq!(
6351- Configuration::from_file(&conf_path)
6352- .unwrap_err()
6353- .display_chain()
6354- .to_string(),
6355- format!(
6356- "[1] Could not parse configuration file `{}` successfully: Caused by:\n[2] \
6357- Error: expected an equals, found an identifier at line 1 column 8\n",
6358- conf_path.display()
6359- ),
6360- );
6361- }
6362- }
6363 diff --git a/core/src/connection.rs b/core/src/connection.rs
6364deleted file mode 100644
6365index 5f122eb..0000000
6366--- a/core/src/connection.rs
6367+++ /dev/null
6368 @@ -1,1381 +0,0 @@
6369- /*
6370- * This file is part of mailpot
6371- *
6372- * Copyright 2020 - Manos Pitsidianakis
6373- *
6374- * This program is free software: you can redistribute it and/or modify
6375- * it under the terms of the GNU Affero General Public License as
6376- * published by the Free Software Foundation, either version 3 of the
6377- * License, or (at your option) any later version.
6378- *
6379- * This program is distributed in the hope that it will be useful,
6380- * but WITHOUT ANY WARRANTY; without even the implied warranty of
6381- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
6382- * GNU Affero General Public License for more details.
6383- *
6384- * You should have received a copy of the GNU Affero General Public License
6385- * along with this program. If not, see <https://www.gnu.org/licenses/>.
6386- */
6387-
6388- //! Mailpot database and methods.
6389-
6390- use std::{
6391- io::Write,
6392- process::{Command, Stdio},
6393- };
6394-
6395- use jsonschema::JSONSchema;
6396- use log::{info, trace};
6397- use rusqlite::{functions::FunctionFlags, Connection as DbConnection, OptionalExtension};
6398-
6399- use crate::{
6400- config::Configuration,
6401- errors::{ErrorKind::*, *},
6402- models::{changesets::MailingListChangeset, DbVal, ListOwner, MailingList, Post},
6403- StripCarets,
6404- };
6405-
6406- /// A connection to a `mailpot` database.
6407- pub struct Connection {
6408- /// The `rusqlite` connection handle.
6409- pub connection: DbConnection,
6410- pub(crate) conf: Configuration,
6411- }
6412-
6413- impl std::fmt::Debug for Connection {
6414- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
6415- fmt.debug_struct("Connection")
6416- .field("conf", &self.conf)
6417- .finish()
6418- }
6419- }
6420-
6421- impl Drop for Connection {
6422- fn drop(&mut self) {
6423- self.connection
6424- .authorizer::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>(
6425- None,
6426- );
6427- // make sure pragma optimize does not take too long
6428- _ = self.connection.pragma_update(None, "analysis_limit", "400");
6429- // gather statistics to improve query optimization
6430- _ = self
6431- .connection
6432- .pragma(None, "optimize", 0xfffe_i64, |_| Ok(()));
6433- }
6434- }
6435-
6436- fn log_callback(error_code: std::ffi::c_int, message: &str) {
6437- match error_code {
6438- rusqlite::ffi::SQLITE_NOTICE => log::trace!("{}", message),
6439- rusqlite::ffi::SQLITE_OK
6440- | rusqlite::ffi::SQLITE_DONE
6441- | rusqlite::ffi::SQLITE_NOTICE_RECOVER_WAL
6442- | rusqlite::ffi::SQLITE_NOTICE_RECOVER_ROLLBACK => log::info!("{}", message),
6443- rusqlite::ffi::SQLITE_WARNING | rusqlite::ffi::SQLITE_WARNING_AUTOINDEX => {
6444- log::warn!("{}", message)
6445- }
6446- _ => log::error!("{error_code} {}", message),
6447- }
6448- }
6449-
6450- fn user_authorizer_callback(
6451- auth_context: rusqlite::hooks::AuthContext<'_>,
6452- ) -> rusqlite::hooks::Authorization {
6453- use rusqlite::hooks::{AuthAction, Authorization};
6454-
6455- // [ref:sync_auth_doc] sync with `untrusted()` rustdoc when changing this.
6456- match auth_context.action {
6457- AuthAction::Delete {
6458- table_name: "queue" | "candidate_subscription" | "subscription",
6459- }
6460- | AuthAction::Insert {
6461- table_name: "post" | "queue" | "candidate_subscription" | "subscription" | "account",
6462- }
6463- | AuthAction::Update {
6464- table_name: "candidate_subscription" | "template",
6465- column_name: "accepted" | "last_modified" | "verified" | "address",
6466- }
6467- | AuthAction::Update {
6468- table_name: "account",
6469- column_name: "last_modified" | "name" | "public_key" | "password",
6470- }
6471- | AuthAction::Update {
6472- table_name: "subscription",
6473- column_name:
6474- "last_modified"
6475- | "account"
6476- | "digest"
6477- | "verified"
6478- | "hide_address"
6479- | "receive_duplicates"
6480- | "receive_own_posts"
6481- | "receive_confirmation",
6482- }
6483- | AuthAction::Select
6484- | AuthAction::Savepoint { .. }
6485- | AuthAction::Transaction { .. }
6486- | AuthAction::Read { .. }
6487- | AuthAction::Function {
6488- function_name: "count" | "strftime" | "unixepoch" | "datetime",
6489- } => Authorization::Allow,
6490- _ => Authorization::Deny,
6491- }
6492- }
6493-
6494- impl Connection {
6495- /// The database schema.
6496- ///
6497- /// ```sql
6498- #[doc = include_str!("./schema.sql")]
6499- /// ```
6500- pub const SCHEMA: &'static str = include_str!("./schema.sql");
6501-
6502- /// Database migrations.
6503- pub const MIGRATIONS: &'static [(u32, &'static str, &'static str)] =
6504- include!("./migrations.rs.inc");
6505-
6506- /// Creates a new database connection.
6507- ///
6508- /// `Connection` supports a limited subset of operations by default (see
6509- /// [`Connection::untrusted`]).
6510- /// Use [`Connection::trusted`] to remove these limits.
6511- ///
6512- /// # Example
6513- ///
6514- /// ```rust,no_run
6515- /// use mailpot::{Connection, Configuration};
6516- /// use melib::smtp::{SmtpServerConf, SmtpAuth, SmtpSecurity};
6517- /// #
6518- /// # fn main() -> mailpot::Result<()> {
6519- /// # use tempfile::TempDir;
6520- /// #
6521- /// # let tmp_dir = TempDir::new()?;
6522- /// # let db_path = tmp_dir.path().join("mpot.db");
6523- /// # let data_path = tmp_dir.path().to_path_buf();
6524- /// let config = Configuration {
6525- /// send_mail: mailpot::SendMail::Smtp(
6526- /// SmtpServerConf {
6527- /// hostname: "127.0.0.1".into(),
6528- /// port: 25,
6529- /// envelope_from: "foo-chat@example.com".into(),
6530- /// auth: SmtpAuth::None,
6531- /// security: SmtpSecurity::None,
6532- /// extensions: Default::default(),
6533- /// }
6534- /// ),
6535- /// db_path,
6536- /// data_path,
6537- /// administrators: vec![],
6538- /// };
6539- /// # assert_eq!(&Connection::open_db(config.clone()).unwrap_err().to_string(), "Database doesn't exist");
6540- ///
6541- /// let db = Connection::open_or_create_db(config)?;
6542- /// # _ = db;
6543- /// # Ok(())
6544- /// # }
6545- /// ```
6546- pub fn open_db(conf: Configuration) -> Result<Self> {
6547- use std::sync::Once;
6548-
6549- use rusqlite::config::DbConfig;
6550-
6551- static INIT_SQLITE_LOGGING: Once = Once::new();
6552-
6553- if !conf.db_path.exists() {
6554- return Err("Database doesn't exist".into());
6555- }
6556- INIT_SQLITE_LOGGING.call_once(|| {
6557- _ = unsafe { rusqlite::trace::config_log(Some(log_callback)) };
6558- });
6559- let conn = DbConnection::open(conf.db_path.to_str().unwrap()).with_context(|| {
6560- format!("sqlite3 library could not open {}.", conf.db_path.display())
6561- })?;
6562- rusqlite::vtab::array::load_module(&conn)?;
6563- conn.pragma_update(None, "journal_mode", "WAL")?;
6564- conn.pragma_update(None, "foreign_keys", "on")?;
6565- // synchronise less often to the filesystem
6566- conn.pragma_update(None, "synchronous", "normal")?;
6567- conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_FKEY, true)?;
6568- conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)?;
6569- conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
6570- conn.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, true)?;
6571- conn.busy_timeout(core::time::Duration::from_millis(500))?;
6572- conn.busy_handler(Some(|times: i32| -> bool { times < 5 }))?;
6573- conn.create_scalar_function(
6574- "validate_json_schema",
6575- 2,
6576- FunctionFlags::SQLITE_INNOCUOUS
6577- | FunctionFlags::SQLITE_UTF8
6578- | FunctionFlags::SQLITE_DETERMINISTIC,
6579- |ctx| {
6580- if log::log_enabled!(log::Level::Trace) {
6581- rusqlite::trace::log(
6582- rusqlite::ffi::SQLITE_NOTICE,
6583- "validate_json_schema RUNNING",
6584- );
6585- }
6586- let map_err = rusqlite::Error::UserFunctionError;
6587- let schema = ctx.get::<String>(0)?;
6588- let value = ctx.get::<String>(1)?;
6589- let schema_val: serde_json::Value = serde_json::from_str(&schema)
6590- .map_err(Into::into)
6591- .map_err(map_err)?;
6592- let value: serde_json::Value = serde_json::from_str(&value)
6593- .map_err(Into::into)
6594- .map_err(map_err)?;
6595- let compiled = JSONSchema::compile(&schema_val)
6596- .map_err(|err| err.to_string())
6597- .map_err(Into::into)
6598- .map_err(map_err)?;
6599- let x = if let Err(errors) = compiled.validate(&value) {
6600- for err in errors {
6601- rusqlite::trace::log(rusqlite::ffi::SQLITE_WARNING, &err.to_string());
6602- drop(err);
6603- }
6604- Ok(false)
6605- } else {
6606- Ok(true)
6607- };
6608- x
6609- },
6610- )?;
6611-
6612- let ret = Self {
6613- conf,
6614- connection: conn,
6615- };
6616- if let Some(&(latest, _, _)) = Self::MIGRATIONS.last() {
6617- let version = ret.schema_version()?;
6618- trace!(
6619- "SQLITE user_version PRAGMA returned {version}. Most recent migration is {latest}."
6620- );
6621- if version < latest {
6622- info!("Updating database schema from version {version} to {latest}...");
6623- }
6624- ret.migrate(version, latest)?;
6625- }
6626-
6627- ret.connection.authorizer(Some(user_authorizer_callback));
6628- Ok(ret)
6629- }
6630-
6631- /// The version of the current schema.
6632- pub fn schema_version(&self) -> Result<u32> {
6633- Ok(self
6634- .connection
6635- .prepare("SELECT user_version FROM pragma_user_version;")?
6636- .query_row([], |row| {
6637- let v: u32 = row.get(0)?;
6638- Ok(v)
6639- })?)
6640- }
6641-
6642- /// Migrate from version `from` to `to`.
6643- ///
6644- /// See [Self::MIGRATIONS].
6645- pub fn migrate(&self, mut from: u32, to: u32) -> Result<()> {
6646- if from == to {
6647- return Ok(());
6648- }
6649-
6650- let undo = from > to;
6651- let tx = self.savepoint(Some(stringify!(migrate)))?;
6652-
6653- while from != to {
6654- log::trace!(
6655- "exec migration from {from} to {to}, type: {}do",
6656- if undo { "un " } else { "re" }
6657- );
6658- if undo {
6659- trace!("{}", Self::MIGRATIONS[from as usize - 1].2);
6660- tx.connection
6661- .execute_batch(Self::MIGRATIONS[from as usize - 1].2)?;
6662- from -= 1;
6663- } else {
6664- trace!("{}", Self::MIGRATIONS[from as usize].1);
6665- tx.connection
6666- .execute_batch(Self::MIGRATIONS[from as usize].1)?;
6667- from += 1;
6668- }
6669- }
6670- tx.connection
6671- .pragma_update(None, "user_version", Self::MIGRATIONS[to as usize - 1].0)?;
6672-
6673- tx.commit()?;
6674-
6675- Ok(())
6676- }
6677-
6678- /// Removes operational limits from this connection. (see
6679- /// [`Connection::untrusted`])
6680- #[must_use]
6681- pub fn trusted(self) -> Self {
6682- self.connection
6683- .authorizer::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>(
6684- None,
6685- );
6686- self
6687- }
6688-
6689- // [tag:sync_auth_doc]
6690- /// Sets operational limits for this connection.
6691- ///
6692- /// - Allow `INSERT`, `DELETE` only for "queue", "candidate_subscription",
6693- /// "subscription".
6694- /// - Allow `UPDATE` only for "subscription" user facing settings.
6695- /// - Allow `INSERT` only for "post".
6696- /// - Allow read access to all tables.
6697- /// - Allow `SELECT`, `TRANSACTION`, `SAVEPOINT`, and the `strftime`
6698- /// function.
6699- /// - Deny everything else.
6700- pub fn untrusted(self) -> Self {
6701- self.connection.authorizer(Some(user_authorizer_callback));
6702- self
6703- }
6704-
6705- /// Create a database if it doesn't exist and then open it.
6706- pub fn open_or_create_db(conf: Configuration) -> Result<Self> {
6707- if !conf.db_path.exists() {
6708- let db_path = &conf.db_path;
6709- use std::os::unix::fs::PermissionsExt;
6710-
6711- info!("Creating database in {}", db_path.display());
6712- std::fs::File::create(db_path).context("Could not create db path")?;
6713-
6714- let mut child =
6715- Command::new(std::env::var("SQLITE_BIN").unwrap_or_else(|_| "sqlite3".into()))
6716- .arg(db_path)
6717- .stdin(Stdio::piped())
6718- .stdout(Stdio::piped())
6719- .stderr(Stdio::piped())
6720- .spawn()
6721- .with_context(|| {
6722- format!(
6723- "Could not launch {} {}.",
6724- std::env::var("SQLITE_BIN").unwrap_or_else(|_| "sqlite3".into()),
6725- db_path.display()
6726- )
6727- })?;
6728- let mut stdin = child.stdin.take().unwrap();
6729- std::thread::spawn(move || {
6730- stdin
6731- .write_all(Self::SCHEMA.as_bytes())
6732- .expect("failed to write to stdin");
6733- if !Self::MIGRATIONS.is_empty() {
6734- stdin
6735- .write_all(b"\nPRAGMA user_version = ")
6736- .expect("failed to write to stdin");
6737- stdin
6738- .write_all(
6739- Self::MIGRATIONS[Self::MIGRATIONS.len() - 1]
6740- .0
6741- .to_string()
6742- .as_bytes(),
6743- )
6744- .expect("failed to write to stdin");
6745- stdin.write_all(b";").expect("failed to write to stdin");
6746- }
6747- stdin.flush().expect("could not flush stdin");
6748- });
6749- let output = child.wait_with_output()?;
6750- if !output.status.success() {
6751- return Err(format!(
6752- "Could not initialize sqlite3 database at {}: sqlite3 returned exit code {} \
6753- and stderr {} {}",
6754- db_path.display(),
6755- output.status.code().unwrap_or_default(),
6756- String::from_utf8_lossy(&output.stderr),
6757- String::from_utf8_lossy(&output.stdout)
6758- )
6759- .into());
6760- }
6761-
6762- let file = std::fs::File::open(db_path)
6763- .with_context(|| format!("Could not open database {}.", db_path.display()))?;
6764- let metadata = file
6765- .metadata()
6766- .with_context(|| format!("Could not fstat database {}.", db_path.display()))?;
6767- let mut permissions = metadata.permissions();
6768-
6769- permissions.set_mode(0o600); // Read/write for owner only.
6770- file.set_permissions(permissions)
6771- .with_context(|| format!("Could not chmod 600 database {}.", db_path.display()))?;
6772- }
6773- Self::open_db(conf)
6774- }
6775-
6776- /// Returns a connection's configuration.
6777- pub fn conf(&self) -> &Configuration {
6778- &self.conf
6779- }
6780-
6781- /// Loads archive databases from [`Configuration::data_path`], if any.
6782- pub fn load_archives(&self) -> Result<()> {
6783- let tx = self.savepoint(Some(stringify!(load_archives)))?;
6784- {
6785- let mut stmt = tx.connection.prepare("ATTACH ? AS ?;")?;
6786- for archive in std::fs::read_dir(&self.conf.data_path)? {
6787- let archive = archive?;
6788- let path = archive.path();
6789- let name = path.file_name().unwrap_or_default();
6790- if path == self.conf.db_path {
6791- continue;
6792- }
6793- stmt.execute(rusqlite::params![
6794- path.to_str().unwrap(),
6795- name.to_str().unwrap()
6796- ])?;
6797- }
6798- }
6799- tx.commit()?;
6800-
6801- Ok(())
6802- }
6803-
6804- /// Returns a vector of existing mailing lists.
6805- pub fn lists(&self) -> Result<Vec<DbVal<MailingList>>> {
6806- let mut stmt = self.connection.prepare("SELECT * FROM list;")?;
6807- let list_iter = stmt.query_map([], |row| {
6808- let pk = row.get("pk")?;
6809- let topics: serde_json::Value = row.get("topics")?;
6810- let topics = MailingList::topics_from_json_value(topics)?;
6811- Ok(DbVal(
6812- MailingList {
6813- pk,
6814- name: row.get("name")?,
6815- id: row.get("id")?,
6816- address: row.get("address")?,
6817- description: row.get("description")?,
6818- topics,
6819- archive_url: row.get("archive_url")?,
6820- },
6821- pk,
6822- ))
6823- })?;
6824-
6825- let mut ret = vec![];
6826- for list in list_iter {
6827- let list = list?;
6828- ret.push(list);
6829- }
6830- Ok(ret)
6831- }
6832-
6833- /// Fetch a mailing list by primary key.
6834- pub fn list(&self, pk: i64) -> Result<Option<DbVal<MailingList>>> {
6835- let mut stmt = self
6836- .connection
6837- .prepare("SELECT * FROM list WHERE pk = ?;")?;
6838- let ret = stmt
6839- .query_row([&pk], |row| {
6840- let pk = row.get("pk")?;
6841- let topics: serde_json::Value = row.get("topics")?;
6842- let topics = MailingList::topics_from_json_value(topics)?;
6843- Ok(DbVal(
6844- MailingList {
6845- pk,
6846- name: row.get("name")?,
6847- id: row.get("id")?,
6848- address: row.get("address")?,
6849- description: row.get("description")?,
6850- topics,
6851- archive_url: row.get("archive_url")?,
6852- },
6853- pk,
6854- ))
6855- })
6856- .optional()?;
6857- Ok(ret)
6858- }
6859-
6860- /// Fetch a mailing list by id.
6861- pub fn list_by_id<S: AsRef<str>>(&self, id: S) -> Result<Option<DbVal<MailingList>>> {
6862- let id = id.as_ref();
6863- let mut stmt = self
6864- .connection
6865- .prepare("SELECT * FROM list WHERE id = ?;")?;
6866- let ret = stmt
6867- .query_row([&id], |row| {
6868- let pk = row.get("pk")?;
6869- let topics: serde_json::Value = row.get("topics")?;
6870- let topics = MailingList::topics_from_json_value(topics)?;
6871- Ok(DbVal(
6872- MailingList {
6873- pk,
6874- name: row.get("name")?,
6875- id: row.get("id")?,
6876- address: row.get("address")?,
6877- description: row.get("description")?,
6878- topics,
6879- archive_url: row.get("archive_url")?,
6880- },
6881- pk,
6882- ))
6883- })
6884- .optional()?;
6885-
6886- Ok(ret)
6887- }
6888-
6889- /// Create a new list.
6890- pub fn create_list(&self, new_val: MailingList) -> Result<DbVal<MailingList>> {
6891- let mut stmt = self.connection.prepare(
6892- "INSERT INTO list(name, id, address, description, archive_url, topics) VALUES(?, ?, \
6893- ?, ?, ?, ?) RETURNING *;",
6894- )?;
6895- let ret = stmt.query_row(
6896- rusqlite::params![
6897- &new_val.name,
6898- &new_val.id,
6899- &new_val.address,
6900- new_val.description.as_ref(),
6901- new_val.archive_url.as_ref(),
6902- serde_json::json!(new_val.topics.as_slice()),
6903- ],
6904- |row| {
6905- let pk = row.get("pk")?;
6906- let topics: serde_json::Value = row.get("topics")?;
6907- let topics = MailingList::topics_from_json_value(topics)?;
6908- Ok(DbVal(
6909- MailingList {
6910- pk,
6911- name: row.get("name")?,
6912- id: row.get("id")?,
6913- address: row.get("address")?,
6914- description: row.get("description")?,
6915- topics,
6916- archive_url: row.get("archive_url")?,
6917- },
6918- pk,
6919- ))
6920- },
6921- )?;
6922-
6923- trace!("create_list {:?}.", &ret);
6924- Ok(ret)
6925- }
6926-
6927- /// Fetch all posts of a mailing list.
6928- pub fn list_posts(
6929- &self,
6930- list_pk: i64,
6931- _date_range: Option<(String, String)>,
6932- ) -> Result<Vec<DbVal<Post>>> {
6933- let mut stmt = self.connection.prepare(
6934- "SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
6935- FROM post WHERE list = ? ORDER BY timestamp ASC;",
6936- )?;
6937- let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
6938- let pk = row.get("pk")?;
6939- Ok(DbVal(
6940- Post {
6941- pk,
6942- list: row.get("list")?,
6943- envelope_from: row.get("envelope_from")?,
6944- address: row.get("address")?,
6945- message_id: row.get("message_id")?,
6946- message: row.get("message")?,
6947- timestamp: row.get("timestamp")?,
6948- datetime: row.get("datetime")?,
6949- month_year: row.get("month_year")?,
6950- },
6951- pk,
6952- ))
6953- })?;
6954- let mut ret = vec![];
6955- for post in iter {
6956- let post = post?;
6957- ret.push(post);
6958- }
6959-
6960- trace!("list_posts {:?}.", &ret);
6961- Ok(ret)
6962- }
6963-
6964- /// Fetch the contents of a single thread in the form of `(depth, post)`
6965- /// where `depth` is the reply distance between a message and the thread
6966- /// root message.
6967- pub fn list_thread(&self, list_pk: i64, root: &str) -> Result<Vec<(i64, DbVal<Post>)>> {
6968- let mut stmt = self
6969- .connection
6970- .prepare(
6971- "WITH RECURSIVE cte_replies AS MATERIALIZED
6972- (
6973- SELECT
6974- pk,
6975- message_id,
6976- REPLACE(
6977- TRIM(
6978- SUBSTR(
6979- CAST(message AS TEXT),
6980- INSTR(
6981- CAST(message AS TEXT),
6982- 'In-Reply-To: '
6983- )
6984- +
6985- LENGTH('in-reply-to: '),
6986- INSTR(
6987- SUBSTR(
6988- CAST(message AS TEXT),
6989- INSTR(
6990- CAST(message AS TEXT),
6991- 'In-Reply-To: ')
6992- +
6993- LENGTH('in-reply-to: ')
6994- ),
6995- '>'
6996- )
6997- )
6998- ),
6999- ' ',
7000- ''
7001- ) AS in_reply_to,
7002- INSTR(
7003- CAST(message AS TEXT),
7004- 'In-Reply-To: '
7005- ) AS offset
7006- FROM post
7007- WHERE
7008- offset > 0
7009- UNION
7010- SELECT
7011- pk,
7012- message_id,
7013- NULL AS in_reply_to,
7014- INSTR(
7015- CAST(message AS TEXT),
7016- 'In-Reply-To: '
7017- ) AS offset
7018- FROM post
7019- WHERE
7020- offset = 0
7021- ),
7022- cte_thread(parent, root, depth) AS (
7023- SELECT DISTINCT
7024- message_id AS parent,
7025- message_id AS root,
7026- 0 AS depth
7027- FROM cte_replies
7028- WHERE
7029- in_reply_to IS NULL
7030- UNION ALL
7031- SELECT
7032- t.message_id AS parent,
7033- cte_thread.root AS root,
7034- (cte_thread.depth + 1) AS depth
7035- FROM cte_replies
7036- AS t
7037- JOIN
7038- cte_thread
7039- ON cte_thread.parent = t.in_reply_to
7040- WHERE t.in_reply_to IS NOT NULL
7041- )
7042- SELECT * FROM cte_thread WHERE root = ? ORDER BY root, depth;",
7043- )
7044- .unwrap();
7045- let iter = stmt.query_map(rusqlite::params![root], |row| {
7046- let parent: String = row.get("parent")?;
7047- let root: String = row.get("root")?;
7048- let depth: i64 = row.get("depth")?;
7049- Ok((parent, root, depth))
7050- })?;
7051- let mut ret = vec![];
7052- for post in iter {
7053- ret.push(post?);
7054- }
7055- let posts = self.list_posts(list_pk, None)?;
7056- let ret = ret
7057- .into_iter()
7058- .filter_map(|(m, _, depth)| {
7059- posts
7060- .iter()
7061- .find(|p| m.as_str().strip_carets() == p.message_id.as_str().strip_carets())
7062- .map(|p| (depth, p.clone()))
7063- })
7064- .skip(1)
7065- .collect();
7066- Ok(ret)
7067- }
7068-
7069- /// Export a list, message, or thread in mbox format
7070- pub fn export_mbox(
7071- &self,
7072- pk: i64,
7073- message_id: Option<&str>,
7074- as_thread: bool,
7075- ) -> Result<Vec<u8>> {
7076- let posts: Result<Vec<DbVal<Post>>> = {
7077- if let Some(message_id) = message_id {
7078- if as_thread {
7079- // export a thread
7080- let thread = self.list_thread(pk, message_id)?;
7081- Ok(thread.iter().map(|item| item.1.clone()).collect())
7082- } else {
7083- // export a single message
7084- let message =
7085- self.list_post_by_message_id(pk, message_id)?
7086- .ok_or_else(|| {
7087- Error::from(format!("no message with id: {}", message_id))
7088- })?;
7089- Ok(vec![message])
7090- }
7091- } else {
7092- // export the entire mailing list
7093- let posts = self.list_posts(pk, None)?;
7094- Ok(posts)
7095- }
7096- };
7097- let mut buf: Vec<u8> = Vec::new();
7098- let mailbox = melib::mbox::MboxFormat::default();
7099- for post in posts? {
7100- let envelope_from = if let Some(address) = post.0.envelope_from {
7101- let address = melib::Address::try_from(address.as_str())?;
7102- Some(address)
7103- } else {
7104- None
7105- };
7106- let envelope = melib::Envelope::from_bytes(&post.0.message, None)?;
7107- mailbox.append(
7108- &mut buf,
7109- &post.0.message.to_vec(),
7110- envelope_from.as_ref(),
7111- Some(envelope.timestamp),
7112- (melib::Flag::PASSED, vec![]),
7113- melib::mbox::MboxMetadata::None,
7114- false,
7115- false,
7116- )?;
7117- }
7118- buf.flush()?;
7119- Ok(buf)
7120- }
7121-
7122- /// Fetch the owners of a mailing list.
7123- pub fn list_owners(&self, pk: i64) -> Result<Vec<DbVal<ListOwner>>> {
7124- let mut stmt = self
7125- .connection
7126- .prepare("SELECT * FROM owner WHERE list = ?;")?;
7127- let list_iter = stmt.query_map([&pk], |row| {
7128- let pk = row.get("pk")?;
7129- Ok(DbVal(
7130- ListOwner {
7131- pk,
7132- list: row.get("list")?,
7133- address: row.get("address")?,
7134- name: row.get("name")?,
7135- },
7136- pk,
7137- ))
7138- })?;
7139-
7140- let mut ret = vec![];
7141- for list in list_iter {
7142- let list = list?;
7143- ret.push(list);
7144- }
7145- Ok(ret)
7146- }
7147-
7148- /// Remove an owner of a mailing list.
7149- pub fn remove_list_owner(&self, list_pk: i64, owner_pk: i64) -> Result<()> {
7150- self.connection
7151- .query_row(
7152- "DELETE FROM owner WHERE list = ? AND pk = ? RETURNING *;",
7153- rusqlite::params![&list_pk, &owner_pk],
7154- |_| Ok(()),
7155- )
7156- .map_err(|err| {
7157- if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
7158- Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
7159- } else {
7160- Error::from(err)
7161- }
7162- })?;
7163- Ok(())
7164- }
7165-
7166- /// Add an owner of a mailing list.
7167- pub fn add_list_owner(&self, list_owner: ListOwner) -> Result<DbVal<ListOwner>> {
7168- let mut stmt = self.connection.prepare(
7169- "INSERT OR REPLACE INTO owner(list, address, name) VALUES (?, ?, ?) RETURNING *;",
7170- )?;
7171- let list_pk = list_owner.list;
7172- let ret = stmt
7173- .query_row(
7174- rusqlite::params![&list_pk, &list_owner.address, &list_owner.name,],
7175- |row| {
7176- let pk = row.get("pk")?;
7177- Ok(DbVal(
7178- ListOwner {
7179- pk,
7180- list: row.get("list")?,
7181- address: row.get("address")?,
7182- name: row.get("name")?,
7183- },
7184- pk,
7185- ))
7186- },
7187- )
7188- .map_err(|err| {
7189- if matches!(
7190- err,
7191- rusqlite::Error::SqliteFailure(
7192- rusqlite::ffi::Error {
7193- code: rusqlite::ffi::ErrorCode::ConstraintViolation,
7194- extended_code: 787
7195- },
7196- _
7197- )
7198- ) {
7199- Error::from(err).chain_err(|| NotFound("Could not find a list with this pk."))
7200- } else {
7201- err.into()
7202- }
7203- })?;
7204-
7205- trace!("add_list_owner {:?}.", &ret);
7206- Ok(ret)
7207- }
7208-
7209- /// Update a mailing list.
7210- pub fn update_list(&self, change_set: MailingListChangeset) -> Result<()> {
7211- if matches!(
7212- change_set,
7213- MailingListChangeset {
7214- pk: _,
7215- name: None,
7216- id: None,
7217- address: None,
7218- description: None,
7219- archive_url: None,
7220- owner_local_part: None,
7221- request_local_part: None,
7222- verify: None,
7223- hidden: None,
7224- enabled: None,
7225- }
7226- ) {
7227- return self.list(change_set.pk).map(|_| ());
7228- }
7229-
7230- let MailingListChangeset {
7231- pk,
7232- name,
7233- id,
7234- address,
7235- description,
7236- archive_url,
7237- owner_local_part,
7238- request_local_part,
7239- verify,
7240- hidden,
7241- enabled,
7242- } = change_set;
7243- let tx = self.savepoint(Some(stringify!(update_list)))?;
7244-
7245- macro_rules! update {
7246- ($field:tt) => {{
7247- if let Some($field) = $field {
7248- tx.connection.execute(
7249- concat!("UPDATE list SET ", stringify!($field), " = ? WHERE pk = ?;"),
7250- rusqlite::params![&$field, &pk],
7251- )?;
7252- }
7253- }};
7254- }
7255- update!(name);
7256- update!(id);
7257- update!(address);
7258- update!(description);
7259- update!(archive_url);
7260- update!(owner_local_part);
7261- update!(request_local_part);
7262- update!(verify);
7263- update!(hidden);
7264- update!(enabled);
7265-
7266- tx.commit()?;
7267- Ok(())
7268- }
7269-
7270- /// Execute operations inside an SQL transaction.
7271- pub fn transaction(
7272- &'_ mut self,
7273- behavior: transaction::TransactionBehavior,
7274- ) -> Result<transaction::Transaction<'_>> {
7275- use transaction::*;
7276-
7277- let query = match behavior {
7278- TransactionBehavior::Deferred => "BEGIN DEFERRED",
7279- TransactionBehavior::Immediate => "BEGIN IMMEDIATE",
7280- TransactionBehavior::Exclusive => "BEGIN EXCLUSIVE",
7281- };
7282- self.connection.execute_batch(query)?;
7283- Ok(Transaction {
7284- conn: self,
7285- drop_behavior: DropBehavior::Rollback,
7286- })
7287- }
7288-
7289- /// Execute operations inside an SQL savepoint.
7290- pub fn savepoint(&'_ self, name: Option<&'static str>) -> Result<transaction::Savepoint<'_>> {
7291- use std::sync::atomic::{AtomicUsize, Ordering};
7292-
7293- use transaction::*;
7294- static COUNTER: AtomicUsize = AtomicUsize::new(0);
7295-
7296- let name = name
7297- .map(Ok)
7298- .unwrap_or_else(|| Err(COUNTER.fetch_add(1, Ordering::Relaxed)));
7299-
7300- match name {
7301- Ok(ref n) => self.connection.execute_batch(&format!("SAVEPOINT {n}"))?,
7302- Err(ref i) => self.connection.execute_batch(&format!("SAVEPOINT _{i}"))?,
7303- };
7304-
7305- Ok(Savepoint {
7306- conn: self,
7307- drop_behavior: DropBehavior::Rollback,
7308- name,
7309- committed: false,
7310- })
7311- }
7312- }
7313-
7314- /// Execute operations inside an SQL transaction.
7315- pub mod transaction {
7316- use super::*;
7317-
7318- /// A transaction handle.
7319- #[derive(Debug)]
7320- pub struct Transaction<'conn> {
7321- pub(super) conn: &'conn mut Connection,
7322- pub(super) drop_behavior: DropBehavior,
7323- }
7324-
7325- impl Drop for Transaction<'_> {
7326- fn drop(&mut self) {
7327- _ = self.finish_();
7328- }
7329- }
7330-
7331- impl Transaction<'_> {
7332- /// Commit and consume transaction.
7333- pub fn commit(mut self) -> Result<()> {
7334- self.commit_()
7335- }
7336-
7337- fn commit_(&mut self) -> Result<()> {
7338- self.conn.connection.execute_batch("COMMIT")?;
7339- Ok(())
7340- }
7341-
7342- /// Configure the transaction to perform the specified action when it is
7343- /// dropped.
7344- #[inline]
7345- pub fn set_drop_behavior(&mut self, drop_behavior: DropBehavior) {
7346- self.drop_behavior = drop_behavior;
7347- }
7348-
7349- /// A convenience method which consumes and rolls back a transaction.
7350- #[inline]
7351- pub fn rollback(mut self) -> Result<()> {
7352- self.rollback_()
7353- }
7354-
7355- fn rollback_(&mut self) -> Result<()> {
7356- self.conn.connection.execute_batch("ROLLBACK")?;
7357- Ok(())
7358- }
7359-
7360- /// Consumes the transaction, committing or rolling back according to
7361- /// the current setting (see `drop_behavior`).
7362- ///
7363- /// Functionally equivalent to the `Drop` implementation, but allows
7364- /// callers to see any errors that occur.
7365- #[inline]
7366- pub fn finish(mut self) -> Result<()> {
7367- self.finish_()
7368- }
7369-
7370- #[inline]
7371- fn finish_(&mut self) -> Result<()> {
7372- if self.conn.connection.is_autocommit() {
7373- return Ok(());
7374- }
7375- match self.drop_behavior {
7376- DropBehavior::Commit => self.commit_().or_else(|_| self.rollback_()),
7377- DropBehavior::Rollback => self.rollback_(),
7378- DropBehavior::Ignore => Ok(()),
7379- DropBehavior::Panic => panic!("Transaction dropped unexpectedly."),
7380- }
7381- }
7382- }
7383-
7384- impl std::ops::Deref for Transaction<'_> {
7385- type Target = Connection;
7386-
7387- #[inline]
7388- fn deref(&self) -> &Connection {
7389- self.conn
7390- }
7391- }
7392-
7393- /// Options for transaction behavior. See [BEGIN
7394- /// TRANSACTION](http://www.sqlite.org/lang_transaction.html) for details.
7395- #[derive(Copy, Clone, Default)]
7396- #[non_exhaustive]
7397- pub enum TransactionBehavior {
7398- /// DEFERRED means that the transaction does not actually start until
7399- /// the database is first accessed.
7400- Deferred,
7401- #[default]
7402- /// IMMEDIATE cause the database connection to start a new write
7403- /// immediately, without waiting for a writes statement.
7404- Immediate,
7405- /// EXCLUSIVE prevents other database connections from reading the
7406- /// database while the transaction is underway.
7407- Exclusive,
7408- }
7409-
7410- /// Options for how a Transaction or Savepoint should behave when it is
7411- /// dropped.
7412- #[derive(Default, Copy, Clone, Debug, PartialEq, Eq)]
7413- #[non_exhaustive]
7414- pub enum DropBehavior {
7415- #[default]
7416- /// Roll back the changes. This is the default.
7417- Rollback,
7418-
7419- /// Commit the changes.
7420- Commit,
7421-
7422- /// Do not commit or roll back changes - this will leave the transaction
7423- /// or savepoint open, so should be used with care.
7424- Ignore,
7425-
7426- /// Panic. Used to enforce intentional behavior during development.
7427- Panic,
7428- }
7429-
7430- /// A savepoint handle.
7431- #[derive(Debug)]
7432- pub struct Savepoint<'conn> {
7433- pub(super) conn: &'conn Connection,
7434- pub(super) drop_behavior: DropBehavior,
7435- pub(super) name: std::result::Result<&'static str, usize>,
7436- pub(super) committed: bool,
7437- }
7438-
7439- impl Drop for Savepoint<'_> {
7440- fn drop(&mut self) {
7441- _ = self.finish_();
7442- }
7443- }
7444-
7445- impl Savepoint<'_> {
7446- /// Commit and consume savepoint.
7447- pub fn commit(mut self) -> Result<()> {
7448- self.commit_()
7449- }
7450-
7451- fn commit_(&mut self) -> Result<()> {
7452- if !self.committed {
7453- match self.name {
7454- Ok(ref n) => self
7455- .conn
7456- .connection
7457- .execute_batch(&format!("RELEASE SAVEPOINT {n}"))?,
7458- Err(ref i) => self
7459- .conn
7460- .connection
7461- .execute_batch(&format!("RELEASE SAVEPOINT _{i}"))?,
7462- };
7463- self.committed = true;
7464- }
7465- Ok(())
7466- }
7467-
7468- /// Configure the savepoint to perform the specified action when it is
7469- /// dropped.
7470- #[inline]
7471- pub fn set_drop_behavior(&mut self, drop_behavior: DropBehavior) {
7472- self.drop_behavior = drop_behavior;
7473- }
7474-
7475- /// A convenience method which consumes and rolls back a savepoint.
7476- #[inline]
7477- pub fn rollback(mut self) -> Result<()> {
7478- self.rollback_()
7479- }
7480-
7481- fn rollback_(&mut self) -> Result<()> {
7482- if !self.committed {
7483- match self.name {
7484- Ok(ref n) => self
7485- .conn
7486- .connection
7487- .execute_batch(&format!("ROLLBACK TO SAVEPOINT {n}"))?,
7488- Err(ref i) => self
7489- .conn
7490- .connection
7491- .execute_batch(&format!("ROLLBACK TO SAVEPOINT _{i}"))?,
7492- };
7493- }
7494- Ok(())
7495- }
7496-
7497- /// Consumes the savepoint, committing or rolling back according to
7498- /// the current setting (see `drop_behavior`).
7499- ///
7500- /// Functionally equivalent to the `Drop` implementation, but allows
7501- /// callers to see any errors that occur.
7502- #[inline]
7503- pub fn finish(mut self) -> Result<()> {
7504- self.finish_()
7505- }
7506-
7507- #[inline]
7508- fn finish_(&mut self) -> Result<()> {
7509- if self.conn.connection.is_autocommit() {
7510- return Ok(());
7511- }
7512- match self.drop_behavior {
7513- DropBehavior::Commit => self.commit_().or_else(|_| self.rollback_()),
7514- DropBehavior::Rollback => self.rollback_(),
7515- DropBehavior::Ignore => Ok(()),
7516- DropBehavior::Panic => panic!("Savepoint dropped unexpectedly."),
7517- }
7518- }
7519- }
7520-
7521- impl std::ops::Deref for Savepoint<'_> {
7522- type Target = Connection;
7523-
7524- #[inline]
7525- fn deref(&self) -> &Connection {
7526- self.conn
7527- }
7528- }
7529- }
7530-
7531- #[cfg(test)]
7532- mod tests {
7533- use super::*;
7534-
7535- #[test]
7536- fn test_new_connection() {
7537- use melib::smtp::{SmtpAuth, SmtpSecurity, SmtpServerConf};
7538- use tempfile::TempDir;
7539-
7540- use crate::SendMail;
7541-
7542- let tmp_dir = TempDir::new().unwrap();
7543- let db_path = tmp_dir.path().join("mpot.db");
7544- let data_path = tmp_dir.path().to_path_buf();
7545- let config = Configuration {
7546- send_mail: SendMail::Smtp(SmtpServerConf {
7547- hostname: "127.0.0.1".into(),
7548- port: 25,
7549- envelope_from: "foo-chat@example.com".into(),
7550- auth: SmtpAuth::None,
7551- security: SmtpSecurity::None,
7552- extensions: Default::default(),
7553- }),
7554- db_path,
7555- data_path,
7556- administrators: vec![],
7557- };
7558- assert_eq!(
7559- &Connection::open_db(config.clone()).unwrap_err().to_string(),
7560- "Database doesn't exist"
7561- );
7562-
7563- _ = Connection::open_or_create_db(config).unwrap();
7564- }
7565-
7566- #[test]
7567- fn test_transactions() {
7568- use melib::smtp::{SmtpAuth, SmtpSecurity, SmtpServerConf};
7569- use tempfile::TempDir;
7570-
7571- use super::transaction::*;
7572- use crate::SendMail;
7573-
7574- let tmp_dir = TempDir::new().unwrap();
7575- let db_path = tmp_dir.path().join("mpot.db");
7576- let data_path = tmp_dir.path().to_path_buf();
7577- let config = Configuration {
7578- send_mail: SendMail::Smtp(SmtpServerConf {
7579- hostname: "127.0.0.1".into(),
7580- port: 25,
7581- envelope_from: "foo-chat@example.com".into(),
7582- auth: SmtpAuth::None,
7583- security: SmtpSecurity::None,
7584- extensions: Default::default(),
7585- }),
7586- db_path,
7587- data_path,
7588- administrators: vec![],
7589- };
7590- let list = MailingList {
7591- pk: 0,
7592- name: "".into(),
7593- id: "".into(),
7594- description: None,
7595- topics: vec![],
7596- address: "".into(),
7597- archive_url: None,
7598- };
7599- let mut db = Connection::open_or_create_db(config).unwrap().trusted();
7600-
7601- /* drop rollback */
7602- let mut tx = db.transaction(Default::default()).unwrap();
7603- tx.set_drop_behavior(DropBehavior::Rollback);
7604- let _new = tx.create_list(list.clone()).unwrap();
7605- drop(tx);
7606- assert_eq!(&db.lists().unwrap(), &[]);
7607-
7608- /* drop commit */
7609- let mut tx = db.transaction(Default::default()).unwrap();
7610- tx.set_drop_behavior(DropBehavior::Commit);
7611- let new = tx.create_list(list.clone()).unwrap();
7612- drop(tx);
7613- assert_eq!(&db.lists().unwrap(), &[new.clone()]);
7614-
7615- /* rollback with drop commit */
7616- let mut tx = db.transaction(Default::default()).unwrap();
7617- tx.set_drop_behavior(DropBehavior::Commit);
7618- let _new2 = tx
7619- .create_list(MailingList {
7620- id: "1".into(),
7621- address: "1".into(),
7622- ..list.clone()
7623- })
7624- .unwrap();
7625- tx.rollback().unwrap();
7626- assert_eq!(&db.lists().unwrap(), &[new.clone()]);
7627-
7628- /* tx and then savepoint */
7629- let tx = db.transaction(Default::default()).unwrap();
7630- let sv = tx.savepoint(None).unwrap();
7631- let new2 = sv
7632- .create_list(MailingList {
7633- id: "2".into(),
7634- address: "2".into(),
7635- ..list.clone()
7636- })
7637- .unwrap();
7638- sv.commit().unwrap();
7639- tx.commit().unwrap();
7640- assert_eq!(&db.lists().unwrap(), &[new.clone(), new2.clone()]);
7641-
7642- /* tx and then rollback savepoint */
7643- let tx = db.transaction(Default::default()).unwrap();
7644- let sv = tx.savepoint(None).unwrap();
7645- let _new3 = sv
7646- .create_list(MailingList {
7647- id: "3".into(),
7648- address: "3".into(),
7649- ..list.clone()
7650- })
7651- .unwrap();
7652- sv.rollback().unwrap();
7653- tx.commit().unwrap();
7654- assert_eq!(&db.lists().unwrap(), &[new.clone(), new2.clone()]);
7655-
7656- /* tx, commit savepoint and then rollback commit */
7657- let tx = db.transaction(Default::default()).unwrap();
7658- let sv = tx.savepoint(None).unwrap();
7659- let _new3 = sv
7660- .create_list(MailingList {
7661- id: "3".into(),
7662- address: "3".into(),
7663- ..list.clone()
7664- })
7665- .unwrap();
7666- sv.commit().unwrap();
7667- tx.rollback().unwrap();
7668- assert_eq!(&db.lists().unwrap(), &[new.clone(), new2.clone()]);
7669-
7670- /* nested savepoints */
7671- let tx = db.transaction(Default::default()).unwrap();
7672- let sv = tx.savepoint(None).unwrap();
7673- let sv1 = sv.savepoint(None).unwrap();
7674- let new3 = sv1
7675- .create_list(MailingList {
7676- id: "3".into(),
7677- address: "3".into(),
7678- ..list
7679- })
7680- .unwrap();
7681- sv1.commit().unwrap();
7682- sv.commit().unwrap();
7683- tx.commit().unwrap();
7684- assert_eq!(&db.lists().unwrap(), &[new, new2, new3]);
7685- }
7686-
7687- #[test]
7688- fn test_mbox_export() {
7689- use tempfile::TempDir;
7690-
7691- use crate::SendMail;
7692-
7693- let tmp_dir = TempDir::new().unwrap();
7694- let db_path = tmp_dir.path().join("mpot.db");
7695- let data_path = tmp_dir.path().to_path_buf();
7696- let config = Configuration {
7697- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
7698- db_path,
7699- data_path,
7700- administrators: vec![],
7701- };
7702- let list = MailingList {
7703- pk: 0,
7704- name: "test".into(),
7705- id: "test".into(),
7706- description: None,
7707- topics: vec![],
7708- address: "test@example.com".into(),
7709- archive_url: None,
7710- };
7711-
7712- let test_emails = vec![
7713- r#"From: "User Name" <user@example.com>
7714- To: "test" <test@example.com>
7715- Subject: Hello World
7716-
7717- Hello, this is a message.
7718-
7719- Goodbye!
7720-
7721- "#,
7722- r#"From: "User Name" <user@example.com>
7723- To: "test" <test@example.com>
7724- Subject: Fuu Bar
7725-
7726- Baz,
7727-
7728- Qux!
7729-
7730- "#,
7731- ];
7732- let db = Connection::open_or_create_db(config).unwrap().trusted();
7733- db.create_list(list).unwrap();
7734- for email in test_emails {
7735- let envelope = melib::Envelope::from_bytes(email.as_bytes(), None).unwrap();
7736- db.post(&envelope, email.as_bytes(), false).unwrap();
7737- }
7738- let mbox = String::from_utf8(db.export_mbox(1, None, false).unwrap()).unwrap();
7739- assert!(
7740- mbox.split('\n').fold(0, |accm, line| {
7741- if line.starts_with("From MAILER-DAEMON") {
7742- accm + 1
7743- } else {
7744- accm
7745- }
7746- }) == 2
7747- )
7748- }
7749- }
7750 diff --git a/core/src/doctests/db_setup.rs.inc b/core/src/doctests/db_setup.rs.inc
7751deleted file mode 100644
7752index 46b82ca..0000000
7753--- a/core/src/doctests/db_setup.rs.inc
7754+++ /dev/null
7755 @@ -1,53 +0,0 @@
7756- # use mailpot::{*, models::*};
7757- # use melib::smtp::{SmtpServerConf, SmtpAuth, SmtpSecurity};
7758- #
7759- # use tempfile::TempDir;
7760- #
7761- # let tmp_dir = TempDir::new()?;
7762- # let db_path = tmp_dir.path().join("mpot.db");
7763- # let data_path = tmp_dir.path().to_path_buf();
7764- # let config = Configuration {
7765- # send_mail: mailpot::SendMail::Smtp(
7766- # SmtpServerConf {
7767- # hostname: "127.0.0.1".into(),
7768- # port: 25,
7769- # envelope_from: "foo-chat@example.com".into(),
7770- # auth: SmtpAuth::None,
7771- # security: SmtpSecurity::None,
7772- # extensions: Default::default(),
7773- # }
7774- # ),
7775- # db_path,
7776- # data_path,
7777- # administrators: vec![],
7778- # };
7779- # let db = Connection::open_or_create_db(config)?.trusted();
7780- # let list = db
7781- # .create_list(MailingList {
7782- # pk: 5,
7783- # name: "foobar chat".into(),
7784- # id: "foo-chat".into(),
7785- # address: "foo-chat@example.com".into(),
7786- # description: Some("Hello world, from foo-chat list".into()),
7787- # topics: vec![],
7788- # archive_url: Some("https://lists.example.com".into()),
7789- # })
7790- # .unwrap();
7791- # let sub_policy = SubscriptionPolicy {
7792- # pk: 1,
7793- # list: 5,
7794- # send_confirmation: true,
7795- # open: false,
7796- # manual: false,
7797- # request: true,
7798- # custom: false,
7799- # };
7800- # let post_policy = PostPolicy {
7801- # pk: 1,
7802- # list: 5,
7803- # announce_only: false,
7804- # subscription_only: false,
7805- # approval_needed: false,
7806- # open: true,
7807- # custom: false,
7808- # };
7809 diff --git a/core/src/errors.rs b/core/src/errors.rs
7810deleted file mode 100644
7811index da07e70..0000000
7812--- a/core/src/errors.rs
7813+++ /dev/null
7814 @@ -1,232 +0,0 @@
7815- /*
7816- * This file is part of mailpot
7817- *
7818- * Copyright 2020 - Manos Pitsidianakis
7819- *
7820- * This program is free software: you can redistribute it and/or modify
7821- * it under the terms of the GNU Affero General Public License as
7822- * published by the Free Software Foundation, either version 3 of the
7823- * License, or (at your option) any later version.
7824- *
7825- * This program is distributed in the hope that it will be useful,
7826- * but WITHOUT ANY WARRANTY; without even the implied warranty of
7827- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
7828- * GNU Affero General Public License for more details.
7829- *
7830- * You should have received a copy of the GNU Affero General Public License
7831- * along with this program. If not, see <https://www.gnu.org/licenses/>.
7832- */
7833-
7834- //! Errors of this library.
7835-
7836- use std::sync::Arc;
7837-
7838- use thiserror::Error;
7839-
7840- /// Mailpot library error.
7841- #[derive(Error, Debug)]
7842- pub struct Error {
7843- kind: ErrorKind,
7844- source: Option<Arc<Self>>,
7845- }
7846-
7847- /// Mailpot library error.
7848- #[derive(Error, Debug)]
7849- pub enum ErrorKind {
7850- /// Post rejected.
7851- #[error("Your post has been rejected: {0}")]
7852- PostRejected(String),
7853- /// An entry was not found in the database.
7854- #[error("This {0} is not present in the database.")]
7855- NotFound(&'static str),
7856- /// A request was invalid.
7857- #[error("Your list request has been found invalid: {0}.")]
7858- InvalidRequest(String),
7859- /// An error happened and it was handled internally.
7860- #[error("An error happened and it was handled internally: {0}.")]
7861- Information(String),
7862- /// An error that shouldn't happen and should be reported.
7863- #[error("An error that shouldn't happen and should be reported: {0}.")]
7864- Bug(String),
7865-
7866- /// Error returned from an external user initiated operation such as
7867- /// deserialization or I/O.
7868- #[error("Error: {0}")]
7869- External(#[from] anyhow::Error),
7870- /// Generic
7871- #[error("{0}")]
7872- Generic(anyhow::Error),
7873- /// Error returned from sqlite3.
7874- #[error("Error returned from sqlite3: {0}.")]
7875- Sql(
7876- #[from]
7877- #[source]
7878- rusqlite::Error,
7879- ),
7880- /// Error returned from sqlite3.
7881- #[error("Error returned from sqlite3: {0}")]
7882- SqlLib(
7883- #[from]
7884- #[source]
7885- rusqlite::ffi::Error,
7886- ),
7887- /// Error returned from internal I/O operations.
7888- #[error("Error returned from internal I/O operation: {0}")]
7889- Io(#[from] ::std::io::Error),
7890- /// Error returned from e-mail protocol operations from `melib` crate.
7891- #[error("Error returned from e-mail protocol operations from `melib` crate: {0}")]
7892- Melib(#[from] melib::error::Error),
7893- /// Error from deserializing JSON values.
7894- #[error("Error from deserializing JSON values: {0}")]
7895- SerdeJson(#[from] serde_json::Error),
7896- /// Error returned from minijinja template engine.
7897- #[error("Error returned from minijinja template engine: {0}")]
7898- Template(#[from] minijinja::Error),
7899- }
7900-
7901- impl std::fmt::Display for Error {
7902- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
7903- write!(fmt, "{}", self.kind)
7904- }
7905- }
7906-
7907- impl From<ErrorKind> for Error {
7908- fn from(kind: ErrorKind) -> Self {
7909- Self { kind, source: None }
7910- }
7911- }
7912-
7913- macro_rules! impl_from {
7914- ($ty:ty) => {
7915- impl From<$ty> for Error {
7916- fn from(err: $ty) -> Self {
7917- Self {
7918- kind: err.into(),
7919- source: None,
7920- }
7921- }
7922- }
7923- };
7924- }
7925-
7926- impl_from! { anyhow::Error }
7927- impl_from! { rusqlite::Error }
7928- impl_from! { rusqlite::ffi::Error }
7929- impl_from! { ::std::io::Error }
7930- impl_from! { melib::error::Error }
7931- impl_from! { serde_json::Error }
7932- impl_from! { minijinja::Error }
7933-
7934- impl Error {
7935- /// Helper function to create a new generic error message.
7936- pub fn new_external<S: Into<String>>(msg: S) -> Self {
7937- let msg = msg.into();
7938- ErrorKind::External(anyhow::Error::msg(msg)).into()
7939- }
7940-
7941- /// Chain an error by introducing a new head of the error chain.
7942- pub fn chain_err<E>(self, lambda: impl FnOnce() -> E) -> Self
7943- where
7944- E: Into<Self>,
7945- {
7946- let new_head: Self = lambda().into();
7947- Self {
7948- source: Some(Arc::new(self)),
7949- ..new_head
7950- }
7951- }
7952-
7953- /// Insert a source error into this Error.
7954- pub fn with_source<E>(self, source: E) -> Self
7955- where
7956- E: Into<Self>,
7957- {
7958- Self {
7959- source: Some(Arc::new(source.into())),
7960- ..self
7961- }
7962- }
7963-
7964- /// Getter for the kind field.
7965- pub fn kind(&self) -> &ErrorKind {
7966- &self.kind
7967- }
7968-
7969- /// Display error chain to user.
7970- pub fn display_chain(&'_ self) -> impl std::fmt::Display + '_ {
7971- ErrorChainDisplay {
7972- current: self,
7973- counter: 1,
7974- }
7975- }
7976- }
7977-
7978- impl From<String> for Error {
7979- fn from(s: String) -> Self {
7980- ErrorKind::Generic(anyhow::Error::msg(s)).into()
7981- }
7982- }
7983- impl From<&str> for Error {
7984- fn from(s: &str) -> Self {
7985- ErrorKind::Generic(anyhow::Error::msg(s.to_string())).into()
7986- }
7987- }
7988-
7989- /// Type alias for Mailpot library Results.
7990- pub type Result<T> = std::result::Result<T, Error>;
7991-
7992- struct ErrorChainDisplay<'e> {
7993- current: &'e Error,
7994- counter: usize,
7995- }
7996-
7997- impl std::fmt::Display for ErrorChainDisplay<'_> {
7998- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
7999- if let Some(ref source) = self.current.source {
8000- writeln!(fmt, "[{}] {} Caused by:", self.counter, self.current.kind)?;
8001- Self {
8002- current: source,
8003- counter: self.counter + 1,
8004- }
8005- .fmt(fmt)
8006- } else {
8007- writeln!(fmt, "[{}] {}", self.counter, self.current.kind)?;
8008- Ok(())
8009- }
8010- }
8011- }
8012-
8013- /// adfsa
8014- pub trait Context<T> {
8015- /// Wrap the error value with additional context.
8016- fn context<C>(self, context: C) -> Result<T>
8017- where
8018- C: Into<Error>;
8019-
8020- /// Wrap the error value with additional context that is evaluated lazily
8021- /// only once an error does occur.
8022- fn with_context<C, F>(self, f: F) -> Result<T>
8023- where
8024- C: Into<Error>,
8025- F: FnOnce() -> C;
8026- }
8027-
8028- impl<T, E> Context<T> for std::result::Result<T, E>
8029- where
8030- Error: From<E>,
8031- {
8032- fn context<C>(self, context: C) -> Result<T>
8033- where
8034- C: Into<Error>,
8035- {
8036- self.map_err(|err| Error::from(err).chain_err(|| context.into()))
8037- }
8038-
8039- fn with_context<C, F>(self, f: F) -> Result<T>
8040- where
8041- C: Into<Error>,
8042- F: FnOnce() -> C,
8043- {
8044- self.map_err(|err| Error::from(err).chain_err(|| f().into()))
8045- }
8046- }
8047 diff --git a/core/src/lib.rs b/core/src/lib.rs
8048deleted file mode 100644
8049index e56a80a..0000000
8050--- a/core/src/lib.rs
8051+++ /dev/null
8052 @@ -1,259 +0,0 @@
8053- /*
8054- * This file is part of mailpot
8055- *
8056- * Copyright 2020 - Manos Pitsidianakis
8057- *
8058- * This program is free software: you can redistribute it and/or modify
8059- * it under the terms of the GNU Affero General Public License as
8060- * published by the Free Software Foundation, either version 3 of the
8061- * License, or (at your option) any later version.
8062- *
8063- * This program is distributed in the hope that it will be useful,
8064- * but WITHOUT ANY WARRANTY; without even the implied warranty of
8065- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
8066- * GNU Affero General Public License for more details.
8067- *
8068- * You should have received a copy of the GNU Affero General Public License
8069- * along with this program. If not, see <https://www.gnu.org/licenses/>.
8070- */
8071-
8072- #![deny(
8073- missing_docs,
8074- rustdoc::broken_intra_doc_links,
8075- /* groups */
8076- clippy::correctness,
8077- clippy::suspicious,
8078- clippy::complexity,
8079- clippy::perf,
8080- clippy::style,
8081- clippy::cargo,
8082- clippy::nursery,
8083- /* restriction */
8084- clippy::dbg_macro,
8085- clippy::rc_buffer,
8086- clippy::as_underscore,
8087- clippy::assertions_on_result_states,
8088- /* pedantic */
8089- clippy::cast_lossless,
8090- clippy::cast_possible_wrap,
8091- clippy::ptr_as_ptr,
8092- clippy::bool_to_int_with_if,
8093- clippy::borrow_as_ptr,
8094- clippy::case_sensitive_file_extension_comparisons,
8095- clippy::cast_lossless,
8096- clippy::cast_ptr_alignment,
8097- clippy::naive_bytecount
8098- )]
8099- #![allow(clippy::multiple_crate_versions, clippy::missing_const_for_fn)]
8100-
8101- //! Mailing list manager library.
8102- //!
8103- //! Data is stored in a `sqlite3` database.
8104- //! You can inspect the schema in [`SCHEMA`](crate::Connection::SCHEMA).
8105- //!
8106- //! # Usage
8107- //!
8108- //! `mailpot` can be used with the CLI tool in [`mailpot-cli`](mailpot-cli),
8109- //! and/or in the web interface of the [`mailpot-web`](mailpot-web) crate.
8110- //!
8111- //! You can also directly use this crate as a library.
8112- //!
8113- //! # Example
8114- //!
8115- //! ```
8116- //! use mailpot::{models::*, Configuration, Connection, SendMail};
8117- //! # use tempfile::TempDir;
8118- //!
8119- //! # let tmp_dir = TempDir::new().unwrap();
8120- //! # let db_path = tmp_dir.path().join("mpot.db");
8121- //! # let config = Configuration {
8122- //! # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
8123- //! # db_path: db_path.clone(),
8124- //! # data_path: tmp_dir.path().to_path_buf(),
8125- //! # administrators: vec![],
8126- //! # };
8127- //! #
8128- //! # fn do_test(config: Configuration) -> mailpot::Result<()> {
8129- //! let db = Connection::open_or_create_db(config)?.trusted();
8130- //!
8131- //! // Create a new mailing list
8132- //! let list_pk = db
8133- //! .create_list(MailingList {
8134- //! pk: 0,
8135- //! name: "foobar chat".into(),
8136- //! id: "foo-chat".into(),
8137- //! address: "foo-chat@example.com".into(),
8138- //! topics: vec![],
8139- //! description: None,
8140- //! archive_url: None,
8141- //! })?
8142- //! .pk;
8143- //!
8144- //! db.set_list_post_policy(PostPolicy {
8145- //! pk: 0,
8146- //! list: list_pk,
8147- //! announce_only: false,
8148- //! subscription_only: true,
8149- //! approval_needed: false,
8150- //! open: false,
8151- //! custom: false,
8152- //! })?;
8153- //!
8154- //! // Drop privileges; we can only process new e-mail and modify subscriptions from now on.
8155- //! let mut db = db.untrusted();
8156- //!
8157- //! assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
8158- //! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
8159- //!
8160- //! // Process a subscription request e-mail
8161- //! let subscribe_bytes = b"From: Name <user@example.com>
8162- //! To: <foo-chat+subscribe@example.com>
8163- //! Subject: subscribe
8164- //! Date: Thu, 29 Oct 2020 13:58:16 +0000
8165- //! Message-ID: <1@example.com>
8166- //!
8167- //! ";
8168- //! let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
8169- //! db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
8170- //!
8171- //! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
8172- //! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
8173- //!
8174- //! // Process a post
8175- //! let post_bytes = b"From: Name <user@example.com>
8176- //! To: <foo-chat@example.com>
8177- //! Subject: my first post
8178- //! Date: Thu, 29 Oct 2020 14:01:09 +0000
8179- //! Message-ID: <2@example.com>
8180- //!
8181- //! Hello
8182- //! ";
8183- //! let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
8184- //! db.post(&envelope, post_bytes, /* dry_run */ false)?;
8185- //!
8186- //! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
8187- //! assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
8188- //! # Ok(())
8189- //! # }
8190- //! # do_test(config);
8191- //! ```
8192-
8193- /* Annotations:
8194- *
8195- * Global tags (in tagref format <https://github.com/stepchowfun/tagref>) for source code
8196- * annotation:
8197- *
8198- * - [tag:needs_unit_test]
8199- * - [tag:needs_user_doc]
8200- * - [tag:needs_dev_doc]
8201- * - [tag:FIXME]
8202- * - [tag:TODO]
8203- * - [tag:VERIFY] Verify whether this is the correct way to do something
8204- */
8205-
8206- /// Error library
8207- pub extern crate anyhow;
8208- /// Date library
8209- pub extern crate chrono;
8210- /// Sql library
8211- pub extern crate rusqlite;
8212-
8213- /// Alias for [`chrono::DateTime<chrono::Utc>`].
8214- pub type DateTime = chrono::DateTime<chrono::Utc>;
8215-
8216- /// Serde
8217- #[macro_use]
8218- pub extern crate serde;
8219- /// Log
8220- pub extern crate log;
8221- /// melib
8222- pub extern crate melib;
8223- /// serde_json
8224- pub extern crate serde_json;
8225-
8226- mod config;
8227- mod connection;
8228- mod errors;
8229- pub mod mail;
8230- pub mod message_filters;
8231- pub mod models;
8232- pub mod policies;
8233- #[cfg(not(target_os = "windows"))]
8234- pub mod postfix;
8235- pub mod posts;
8236- pub mod queue;
8237- pub mod submission;
8238- pub mod subscriptions;
8239- mod templates;
8240-
8241- pub use config::{Configuration, SendMail};
8242- pub use connection::{transaction, *};
8243- pub use errors::*;
8244- use models::*;
8245- pub use templates::*;
8246-
8247- /// A `mailto:` value.
8248- #[derive(Debug, Clone, Deserialize, Serialize)]
8249- pub struct MailtoAddress {
8250- /// E-mail address.
8251- pub address: String,
8252- /// Optional subject value.
8253- pub subject: Option<String>,
8254- }
8255-
8256- #[doc = include_str!("../../README.md")]
8257- #[cfg(doctest)]
8258- pub struct ReadmeDoctests;
8259-
8260- /// Trait for stripping carets ('<','>') from Message IDs.
8261- pub trait StripCarets {
8262- /// If `self` is surrounded by carets, strip them.
8263- fn strip_carets(&self) -> &str;
8264- }
8265-
8266- impl StripCarets for &str {
8267- fn strip_carets(&self) -> &str {
8268- let mut self_ref = self.trim();
8269- if self_ref.starts_with('<') && self_ref.ends_with('>') {
8270- self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
8271- }
8272- self_ref
8273- }
8274- }
8275-
8276- /// Trait for stripping carets ('<','>') from Message IDs inplace.
8277- pub trait StripCaretsInplace {
8278- /// If `self` is surrounded by carets, strip them.
8279- fn strip_carets_inplace(self) -> Self;
8280- }
8281-
8282- impl StripCaretsInplace for &str {
8283- fn strip_carets_inplace(self) -> Self {
8284- let mut self_ref = self.trim();
8285- if self_ref.starts_with('<') && self_ref.ends_with('>') {
8286- self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
8287- }
8288- self_ref
8289- }
8290- }
8291-
8292- impl StripCaretsInplace for String {
8293- fn strip_carets_inplace(mut self) -> Self {
8294- if self.starts_with('<') && self.ends_with('>') {
8295- self.drain(0..1);
8296- let len = self.len();
8297- self.drain(len.saturating_sub(1)..len);
8298- }
8299- self
8300- }
8301- }
8302-
8303- use percent_encoding::CONTROLS;
8304- pub use percent_encoding::{utf8_percent_encode, AsciiSet};
8305-
8306- // from https://github.com/servo/rust-url/blob/master/url/src/parser.rs
8307- const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
8308- const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
8309-
8310- /// Set for percent encoding URL components.
8311- pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');
8312 diff --git a/core/src/mail.rs b/core/src/mail.rs
8313deleted file mode 100644
8314index b33e715..0000000
8315--- a/core/src/mail.rs
8316+++ /dev/null
8317 @@ -1,181 +0,0 @@
8318- /*
8319- * This file is part of mailpot
8320- *
8321- * Copyright 2020 - Manos Pitsidianakis
8322- *
8323- * This program is free software: you can redistribute it and/or modify
8324- * it under the terms of the GNU Affero General Public License as
8325- * published by the Free Software Foundation, either version 3 of the
8326- * License, or (at your option) any later version.
8327- *
8328- * This program is distributed in the hope that it will be useful,
8329- * but WITHOUT ANY WARRANTY; without even the implied warranty of
8330- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
8331- * GNU Affero General Public License for more details.
8332- *
8333- * You should have received a copy of the GNU Affero General Public License
8334- * along with this program. If not, see <https://www.gnu.org/licenses/>.
8335- */
8336-
8337- //! Types for processing new posts:
8338- //! [`PostFilter`](crate::message_filters::PostFilter), [`ListContext`],
8339- //! [`MailJob`] and [`PostAction`].
8340-
8341- use std::collections::HashMap;
8342-
8343- use log::trace;
8344- use melib::{Address, MessageID};
8345-
8346- use crate::{
8347- models::{ListOwner, ListSubscription, MailingList, PostPolicy, SubscriptionPolicy},
8348- DbVal,
8349- };
8350- /// Post action returned from a list's
8351- /// [`PostFilter`](crate::message_filters::PostFilter) stack.
8352- #[derive(Debug)]
8353- pub enum PostAction {
8354- /// Add to `hold` queue.
8355- Hold,
8356- /// Accept to mailing list.
8357- Accept,
8358- /// Reject and send rejection response to submitter.
8359- Reject {
8360- /// Human readable reason for rejection.
8361- reason: String,
8362- },
8363- /// Add to `deferred` queue.
8364- Defer {
8365- /// Human readable reason for deferring.
8366- reason: String,
8367- },
8368- }
8369-
8370- /// List context passed to a list's
8371- /// [`PostFilter`](crate::message_filters::PostFilter) stack.
8372- #[derive(Debug)]
8373- pub struct ListContext<'list> {
8374- /// Which mailing list a post was addressed to.
8375- pub list: &'list MailingList,
8376- /// The mailing list owners.
8377- pub list_owners: &'list [DbVal<ListOwner>],
8378- /// The mailing list subscriptions.
8379- pub subscriptions: &'list [DbVal<ListSubscription>],
8380- /// The mailing list post policy.
8381- pub post_policy: Option<DbVal<PostPolicy>>,
8382- /// The mailing list subscription policy.
8383- pub subscription_policy: Option<DbVal<SubscriptionPolicy>>,
8384- /// The scheduled jobs added by each filter in a list's
8385- /// [`PostFilter`](crate::message_filters::PostFilter) stack.
8386- pub scheduled_jobs: Vec<MailJob>,
8387- /// Saved settings for message filters, which process a
8388- /// received e-mail before taking a final decision/action.
8389- pub filter_settings: HashMap<String, DbVal<serde_json::Value>>,
8390- }
8391-
8392- /// Post to be considered by the list's
8393- /// [`PostFilter`](crate::message_filters::PostFilter) stack.
8394- pub struct PostEntry {
8395- /// `From` address of post.
8396- pub from: Address,
8397- /// Raw bytes of post.
8398- pub bytes: Vec<u8>,
8399- /// `To` addresses of post.
8400- pub to: Vec<Address>,
8401- /// Final action set by each filter in a list's
8402- /// [`PostFilter`](crate::message_filters::PostFilter) stack.
8403- pub action: PostAction,
8404- /// Post's Message-ID
8405- pub message_id: MessageID,
8406- }
8407-
8408- impl core::fmt::Debug for PostEntry {
8409- fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::fmt::Result {
8410- fmt.debug_struct(stringify!(PostEntry))
8411- .field("from", &self.from)
8412- .field("message_id", &self.message_id)
8413- .field("bytes", &format_args!("{} bytes", self.bytes.len()))
8414- .field("to", &self.to.as_slice())
8415- .field("action", &self.action)
8416- .finish()
8417- }
8418- }
8419-
8420- /// Scheduled jobs added to a [`ListContext`] by a list's
8421- /// [`PostFilter`](crate::message_filters::PostFilter) stack.
8422- #[derive(Debug)]
8423- pub enum MailJob {
8424- /// Send post to recipients.
8425- Send {
8426- /// The post recipients addresses.
8427- recipients: Vec<Address>,
8428- },
8429- /// Send error to submitter.
8430- Error {
8431- /// Human readable description of the error.
8432- description: String,
8433- },
8434- /// Store post in digest for recipients.
8435- StoreDigest {
8436- /// The digest recipients addresses.
8437- recipients: Vec<Address>,
8438- },
8439- /// Reply with subscription confirmation to submitter.
8440- ConfirmSubscription {
8441- /// The submitter address.
8442- recipient: Address,
8443- },
8444- /// Reply with unsubscription confirmation to submitter.
8445- ConfirmUnsubscription {
8446- /// The submitter address.
8447- recipient: Address,
8448- },
8449- }
8450-
8451- /// Type of mailing list request.
8452- #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
8453- pub enum ListRequest {
8454- /// Get help about a mailing list and its available interfaces.
8455- Help,
8456- /// Request subscription.
8457- Subscribe,
8458- /// Request removal of subscription.
8459- Unsubscribe,
8460- /// Request reception of list posts from a month-year range, inclusive.
8461- RetrieveArchive(String, String),
8462- /// Request reception of specific mailing list posts from `Message-ID`
8463- /// values.
8464- RetrieveMessages(Vec<String>),
8465- /// Request change in subscription settings.
8466- /// See [`ListSubscription`].
8467- ChangeSetting(String, bool),
8468- /// Other type of request.
8469- Other(String),
8470- }
8471-
8472- impl std::fmt::Display for ListRequest {
8473- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
8474- write!(fmt, "{:?}", self)
8475- }
8476- }
8477-
8478- impl<S: AsRef<str>> TryFrom<(S, &melib::Envelope)> for ListRequest {
8479- type Error = crate::Error;
8480-
8481- fn try_from((val, env): (S, &melib::Envelope)) -> std::result::Result<Self, Self::Error> {
8482- let val = val.as_ref();
8483- Ok(match val {
8484- "subscribe" => Self::Subscribe,
8485- "request" if env.subject().trim() == "subscribe" => Self::Subscribe,
8486- "unsubscribe" => Self::Unsubscribe,
8487- "request" if env.subject().trim() == "unsubscribe" => Self::Unsubscribe,
8488- "help" => Self::Help,
8489- "request" if env.subject().trim() == "help" => Self::Help,
8490- "request" => Self::Other(env.subject().trim().to_string()),
8491- _ => {
8492- // [ref:TODO] add ChangeSetting parsing
8493- trace!("unknown action = {} for addresses {:?}", val, env.from(),);
8494- Self::Other(val.trim().to_string())
8495- }
8496- })
8497- }
8498- }
8499 diff --git a/core/src/message_filters.rs b/core/src/message_filters.rs
8500deleted file mode 100644
8501index 553a471..0000000
8502--- a/core/src/message_filters.rs
8503+++ /dev/null
8504 @@ -1,406 +0,0 @@
8505- /*
8506- * This file is part of mailpot
8507- *
8508- * Copyright 2020 - Manos Pitsidianakis
8509- *
8510- * This program is free software: you can redistribute it and/or modify
8511- * it under the terms of the GNU Affero General Public License as
8512- * published by the Free Software Foundation, either version 3 of the
8513- * License, or (at your option) any later version.
8514- *
8515- * This program is distributed in the hope that it will be useful,
8516- * but WITHOUT ANY WARRANTY; without even the implied warranty of
8517- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
8518- * GNU Affero General Public License for more details.
8519- *
8520- * You should have received a copy of the GNU Affero General Public License
8521- * along with this program. If not, see <https://www.gnu.org/licenses/>.
8522- */
8523-
8524- #![allow(clippy::result_unit_err)]
8525-
8526- //! Filters to pass each mailing list post through. Filters are functions that
8527- //! implement the [`PostFilter`] trait that can:
8528- //!
8529- //! - transform post content.
8530- //! - modify the final [`PostAction`] to take.
8531- //! - modify the final scheduled jobs to perform. (See [`MailJob`]).
8532- //!
8533- //! Filters are executed in sequence like this:
8534- //!
8535- //! ```ignore
8536- //! let result = filters
8537- //! .into_iter()
8538- //! .fold(Ok((&mut post, &mut list_ctx)), |p, f| {
8539- //! p.and_then(|(p, c)| f.feed(p, c))
8540- //! });
8541- //! ```
8542- //!
8543- //! so the processing stops at the first returned error.
8544-
8545- mod settings;
8546- use log::trace;
8547- use melib::{Address, HeaderName};
8548- use percent_encoding::utf8_percent_encode;
8549-
8550- use crate::{
8551- mail::{ListContext, MailJob, PostAction, PostEntry},
8552- models::{DbVal, MailingList},
8553- Connection, StripCarets, PATH_SEGMENT,
8554- };
8555-
8556- impl Connection {
8557- /// Return the post filters of a mailing list.
8558- pub fn list_filters(&self, _list: &DbVal<MailingList>) -> Vec<Box<dyn PostFilter>> {
8559- vec![
8560- Box::new(PostRightsCheck),
8561- Box::new(MimeReject),
8562- Box::new(FixCRLF),
8563- Box::new(AddListHeaders),
8564- Box::new(ArchivedAtLink),
8565- Box::new(AddSubjectTagPrefix),
8566- Box::new(FinalizeRecipients),
8567- ]
8568- }
8569- }
8570-
8571- /// Filter that modifies and/or verifies a post candidate. On rejection, return
8572- /// a string describing the error and optionally set `post.action` to `Reject`
8573- /// or `Defer`
8574- pub trait PostFilter {
8575- /// Feed post into the filter. Perform modifications to `post` and / or
8576- /// `ctx`, and return them with `Result::Ok` unless you want to the
8577- /// processing to stop and return an `Result::Err`.
8578- fn feed<'p, 'list>(
8579- self: Box<Self>,
8580- post: &'p mut PostEntry,
8581- ctx: &'p mut ListContext<'list>,
8582- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()>;
8583- }
8584-
8585- /// Check that submitter can post to list, for now it accepts everything.
8586- pub struct PostRightsCheck;
8587- impl PostFilter for PostRightsCheck {
8588- fn feed<'p, 'list>(
8589- self: Box<Self>,
8590- post: &'p mut PostEntry,
8591- ctx: &'p mut ListContext<'list>,
8592- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
8593- trace!("Running PostRightsCheck filter");
8594- if let Some(ref policy) = ctx.post_policy {
8595- if policy.announce_only {
8596- trace!("post policy is announce_only");
8597- let owner_addresses = ctx
8598- .list_owners
8599- .iter()
8600- .map(|lo| lo.address())
8601- .collect::<Vec<Address>>();
8602- trace!("Owner addresses are: {:#?}", &owner_addresses);
8603- trace!("Envelope from is: {:?}", &post.from);
8604- if !owner_addresses.iter().any(|addr| *addr == post.from) {
8605- trace!("Envelope From does not include any owner");
8606- post.action = PostAction::Reject {
8607- reason: "You are not allowed to post on this list.".to_string(),
8608- };
8609- return Err(());
8610- }
8611- } else if policy.subscription_only {
8612- trace!("post policy is subscription_only");
8613- let email_from = post.from.get_email();
8614- trace!("post from is {:?}", &email_from);
8615- trace!("post subscriptions are {:#?}", &ctx.subscriptions);
8616- if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
8617- trace!("Envelope from is not subscribed to this list");
8618- post.action = PostAction::Reject {
8619- reason: "Only subscriptions can post to this list.".to_string(),
8620- };
8621- return Err(());
8622- }
8623- } else if policy.approval_needed {
8624- trace!("post policy says approval_needed");
8625- let email_from = post.from.get_email();
8626- trace!("post from is {:?}", &email_from);
8627- trace!("post subscriptions are {:#?}", &ctx.subscriptions);
8628- if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
8629- trace!("Envelope from is not subscribed to this list");
8630- post.action = PostAction::Defer {
8631- reason: "Your posting has been deferred. Approval from the list's \
8632- moderators is required before it is submitted."
8633- .to_string(),
8634- };
8635- return Err(());
8636- }
8637- }
8638- }
8639- Ok((post, ctx))
8640- }
8641- }
8642-
8643- /// Ensure message contains only `\r\n` line terminators, required by SMTP.
8644- pub struct FixCRLF;
8645- impl PostFilter for FixCRLF {
8646- fn feed<'p, 'list>(
8647- self: Box<Self>,
8648- post: &'p mut PostEntry,
8649- ctx: &'p mut ListContext<'list>,
8650- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
8651- trace!("Running FixCRLF filter");
8652- use std::io::prelude::*;
8653- let mut new_vec = Vec::with_capacity(post.bytes.len());
8654- for line in post.bytes.lines() {
8655- new_vec.extend_from_slice(line.unwrap().as_bytes());
8656- new_vec.extend_from_slice(b"\r\n");
8657- }
8658- post.bytes = new_vec;
8659- Ok((post, ctx))
8660- }
8661- }
8662-
8663- /// Add `List-*` headers
8664- pub struct AddListHeaders;
8665- impl PostFilter for AddListHeaders {
8666- fn feed<'p, 'list>(
8667- self: Box<Self>,
8668- post: &'p mut PostEntry,
8669- ctx: &'p mut ListContext<'list>,
8670- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
8671- trace!("Running AddListHeaders filter");
8672- let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
8673- let sender = format!("<{}>", ctx.list.address);
8674- headers.push((HeaderName::SENDER, sender.as_bytes()));
8675-
8676- let list_id = Some(ctx.list.id_header());
8677- let list_help = ctx.list.help_header();
8678- let list_post = ctx.list.post_header(ctx.post_policy.as_deref());
8679- let list_unsubscribe = ctx
8680- .list
8681- .unsubscribe_header(ctx.subscription_policy.as_deref());
8682- let list_subscribe = ctx
8683- .list
8684- .subscribe_header(ctx.subscription_policy.as_deref());
8685- let list_archive = ctx.list.archive_header();
8686-
8687- for (hdr, val) in [
8688- (HeaderName::LIST_ID, &list_id),
8689- (HeaderName::LIST_HELP, &list_help),
8690- (HeaderName::LIST_POST, &list_post),
8691- (HeaderName::LIST_UNSUBSCRIBE, &list_unsubscribe),
8692- (HeaderName::LIST_SUBSCRIBE, &list_subscribe),
8693- (HeaderName::LIST_ARCHIVE, &list_archive),
8694- ] {
8695- if let Some(val) = val {
8696- headers.push((hdr, val.as_bytes()));
8697- }
8698- }
8699-
8700- let mut new_vec = Vec::with_capacity(
8701- headers
8702- .iter()
8703- .map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
8704- .sum::<usize>()
8705- + "\r\n\r\n".len()
8706- + body.len(),
8707- );
8708- for (h, v) in headers {
8709- new_vec.extend_from_slice(h.as_str().as_bytes());
8710- new_vec.extend_from_slice(b": ");
8711- new_vec.extend_from_slice(v);
8712- new_vec.extend_from_slice(b"\r\n");
8713- }
8714- new_vec.extend_from_slice(b"\r\n\r\n");
8715- new_vec.extend_from_slice(body);
8716-
8717- post.bytes = new_vec;
8718- Ok((post, ctx))
8719- }
8720- }
8721-
8722- /// Add List ID prefix in Subject header (e.g. `[list-id] ...`)
8723- pub struct AddSubjectTagPrefix;
8724- impl PostFilter for AddSubjectTagPrefix {
8725- fn feed<'p, 'list>(
8726- self: Box<Self>,
8727- post: &'p mut PostEntry,
8728- ctx: &'p mut ListContext<'list>,
8729- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
8730- if let Some(mut settings) = ctx.filter_settings.remove("AddSubjectTagPrefixSettings") {
8731- let map = settings.as_object_mut().unwrap();
8732- let enabled = serde_json::from_value::<bool>(map.remove("enabled").unwrap()).unwrap();
8733- if !enabled {
8734- trace!(
8735- "AddSubjectTagPrefix is disabled from settings found for list.pk = {} \
8736- skipping filter",
8737- ctx.list.pk
8738- );
8739- return Ok((post, ctx));
8740- }
8741- }
8742- trace!("Running AddSubjectTagPrefix filter");
8743- let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
8744- let mut subject;
8745- if let Some((_, subj_val)) = headers.iter_mut().find(|(k, _)| k == HeaderName::SUBJECT) {
8746- subject = format!("[{}] ", ctx.list.id).into_bytes();
8747- subject.extend(subj_val.iter().cloned());
8748- *subj_val = subject.as_slice();
8749- } else {
8750- subject = format!("[{}] (no subject)", ctx.list.id).into_bytes();
8751- headers.push((HeaderName::SUBJECT, subject.as_slice()));
8752- }
8753-
8754- let mut new_vec = Vec::with_capacity(
8755- headers
8756- .iter()
8757- .map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
8758- .sum::<usize>()
8759- + "\r\n\r\n".len()
8760- + body.len(),
8761- );
8762- for (h, v) in headers {
8763- new_vec.extend_from_slice(h.as_str().as_bytes());
8764- new_vec.extend_from_slice(b": ");
8765- new_vec.extend_from_slice(v);
8766- new_vec.extend_from_slice(b"\r\n");
8767- }
8768- new_vec.extend_from_slice(b"\r\n\r\n");
8769- new_vec.extend_from_slice(body);
8770-
8771- post.bytes = new_vec;
8772- Ok((post, ctx))
8773- }
8774- }
8775-
8776- /// Adds `Archived-At` field, if configured.
8777- pub struct ArchivedAtLink;
8778- impl PostFilter for ArchivedAtLink {
8779- fn feed<'p, 'list>(
8780- self: Box<Self>,
8781- post: &'p mut PostEntry,
8782- ctx: &'p mut ListContext<'list>,
8783- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
8784- let Some(mut settings) = ctx.filter_settings.remove("ArchivedAtLinkSettings") else {
8785- trace!(
8786- "No ArchivedAtLink settings found for list.pk = {} skipping filter",
8787- ctx.list.pk
8788- );
8789- return Ok((post, ctx));
8790- };
8791- trace!("Running ArchivedAtLink filter");
8792-
8793- let map = settings.as_object_mut().unwrap();
8794- let template = serde_json::from_value::<String>(map.remove("template").unwrap()).unwrap();
8795- let preserve_carets =
8796- serde_json::from_value::<bool>(map.remove("preserve_carets").unwrap()).unwrap();
8797-
8798- let env = minijinja::Environment::new();
8799- let message_id = post.message_id.to_string();
8800- let header_val = env
8801- .render_named_str(
8802- "ArchivedAtLinkSettings.template",
8803- &template,
8804- &if preserve_carets {
8805- minijinja::context! {
8806- msg_id => utf8_percent_encode(message_id.as_str(), PATH_SEGMENT).to_string()
8807- }
8808- } else {
8809- minijinja::context! {
8810- msg_id => utf8_percent_encode(message_id.as_str().strip_carets(), PATH_SEGMENT).to_string()
8811- }
8812- },
8813- )
8814- .map_err(|err| {
8815- log::error!("ArchivedAtLink: {}", err);
8816- })?;
8817- let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
8818- headers.push((HeaderName::ARCHIVED_AT, header_val.as_bytes()));
8819-
8820- let mut new_vec = Vec::with_capacity(
8821- headers
8822- .iter()
8823- .map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
8824- .sum::<usize>()
8825- + "\r\n\r\n".len()
8826- + body.len(),
8827- );
8828- for (h, v) in headers {
8829- new_vec.extend_from_slice(h.as_str().as_bytes());
8830- new_vec.extend_from_slice(b": ");
8831- new_vec.extend_from_slice(v);
8832- new_vec.extend_from_slice(b"\r\n");
8833- }
8834- new_vec.extend_from_slice(b"\r\n\r\n");
8835- new_vec.extend_from_slice(body);
8836-
8837- post.bytes = new_vec;
8838-
8839- Ok((post, ctx))
8840- }
8841- }
8842-
8843- /// Assuming there are no more changes to be done on the post, it finalizes
8844- /// which list subscriptions will receive the post in `post.action` field.
8845- pub struct FinalizeRecipients;
8846- impl PostFilter for FinalizeRecipients {
8847- fn feed<'p, 'list>(
8848- self: Box<Self>,
8849- post: &'p mut PostEntry,
8850- ctx: &'p mut ListContext<'list>,
8851- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
8852- trace!("Running FinalizeRecipients filter");
8853- let mut recipients = vec![];
8854- let mut digests = vec![];
8855- let email_from = post.from.get_email();
8856- for subscription in ctx.subscriptions {
8857- trace!("examining subscription {:?}", &subscription);
8858- if subscription.address == email_from {
8859- trace!("subscription is submitter");
8860- }
8861- if subscription.digest {
8862- if subscription.address != email_from || subscription.receive_own_posts {
8863- trace!("Subscription gets digest");
8864- digests.push(subscription.address());
8865- }
8866- continue;
8867- }
8868- if subscription.address != email_from || subscription.receive_own_posts {
8869- trace!("Subscription gets copy");
8870- recipients.push(subscription.address());
8871- }
8872- }
8873- ctx.scheduled_jobs.push(MailJob::Send { recipients });
8874- if !digests.is_empty() {
8875- ctx.scheduled_jobs.push(MailJob::StoreDigest {
8876- recipients: digests,
8877- });
8878- }
8879- post.action = PostAction::Accept;
8880- Ok((post, ctx))
8881- }
8882- }
8883-
8884- /// Allow specific MIMEs only.
8885- pub struct MimeReject;
8886-
8887- impl PostFilter for MimeReject {
8888- fn feed<'p, 'list>(
8889- self: Box<Self>,
8890- post: &'p mut PostEntry,
8891- ctx: &'p mut ListContext<'list>,
8892- ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
8893- let reject = if let Some(mut settings) = ctx.filter_settings.remove("MimeRejectSettings") {
8894- let map = settings.as_object_mut().unwrap();
8895- let enabled = serde_json::from_value::<bool>(map.remove("enabled").unwrap()).unwrap();
8896- if !enabled {
8897- trace!(
8898- "MimeReject is disabled from settings found for list.pk = {} skipping filter",
8899- ctx.list.pk
8900- );
8901- return Ok((post, ctx));
8902- }
8903- serde_json::from_value::<Vec<String>>(map.remove("reject").unwrap())
8904- } else {
8905- return Ok((post, ctx));
8906- };
8907- trace!("Running MimeReject filter with reject = {:?}", reject);
8908- Ok((post, ctx))
8909- }
8910- }
8911 diff --git a/core/src/message_filters/settings.rs b/core/src/message_filters/settings.rs
8912deleted file mode 100644
8913index bda6c09..0000000
8914--- a/core/src/message_filters/settings.rs
8915+++ /dev/null
8916 @@ -1,44 +0,0 @@
8917- /*
8918- * This file is part of mailpot
8919- *
8920- * Copyright 2023 - Manos Pitsidianakis
8921- *
8922- * This program is free software: you can redistribute it and/or modify
8923- * it under the terms of the GNU Affero General Public License as
8924- * published by the Free Software Foundation, either version 3 of the
8925- * License, or (at your option) any later version.
8926- *
8927- * This program is distributed in the hope that it will be useful,
8928- * but WITHOUT ANY WARRANTY; without even the implied warranty of
8929- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
8930- * GNU Affero General Public License for more details.
8931- *
8932- * You should have received a copy of the GNU Affero General Public License
8933- * along with this program. If not, see <https://www.gnu.org/licenses/>.
8934- */
8935-
8936- //! Named templates, for generated e-mail like confirmations, alerts etc.
8937- //!
8938- //! Template database model: [`Template`](crate::Template).
8939-
8940- use std::collections::HashMap;
8941-
8942- use serde_json::Value;
8943-
8944- use crate::{errors::*, Connection, DbVal};
8945-
8946- impl Connection {
8947- /// Get json settings.
8948- pub fn get_settings(&self, list_pk: i64) -> Result<HashMap<String, DbVal<Value>>> {
8949- let mut stmt = self.connection.prepare(
8950- "SELECT pk, name, value FROM list_settings_json WHERE list = ? AND is_valid = 1;",
8951- )?;
8952- let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
8953- let pk: i64 = row.get("pk")?;
8954- let name: String = row.get("name")?;
8955- let value: Value = row.get("value")?;
8956- Ok((name, DbVal(value, pk)))
8957- })?;
8958- Ok(iter.collect::<std::result::Result<HashMap<String, DbVal<Value>>, rusqlite::Error>>()?)
8959- }
8960- }
8961 diff --git a/core/src/migrations.rs.inc b/core/src/migrations.rs.inc
8962deleted file mode 100644
8963index aa1a2d6..0000000
8964--- a/core/src/migrations.rs.inc
8965+++ /dev/null
8966 @@ -1,277 +0,0 @@
8967-
8968- //(user_version, redo sql, undo sql
8969- &[(1,r##"PRAGMA foreign_keys=ON;
8970- ALTER TABLE templates RENAME TO template;"##,r##"PRAGMA foreign_keys=ON;
8971- ALTER TABLE template RENAME TO templates;"##),(2,r##"PRAGMA foreign_keys=ON;
8972- ALTER TABLE list ADD COLUMN topics JSON NOT NULL CHECK (json_type(topics) == 'array') DEFAULT '[]';"##,r##"PRAGMA foreign_keys=ON;
8973- ALTER TABLE list DROP COLUMN topics;"##),(3,r##"PRAGMA foreign_keys=ON;
8974-
8975- UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk;
8976-
8977- CREATE TRIGGER
8978- IF NOT EXISTS sort_topics_update_trigger
8979- AFTER UPDATE ON list
8980- FOR EACH ROW
8981- WHEN NEW.topics != OLD.topics
8982- BEGIN
8983- UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
8984- END;
8985-
8986- CREATE TRIGGER
8987- IF NOT EXISTS sort_topics_new_trigger
8988- AFTER INSERT ON list
8989- FOR EACH ROW
8990- BEGIN
8991- UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
8992- END;"##,r##"PRAGMA foreign_keys=ON;
8993-
8994- DROP TRIGGER sort_topics_update_trigger;
8995- DROP TRIGGER sort_topics_new_trigger;"##),(4,r##"CREATE TABLE IF NOT EXISTS settings_json_schema (
8996- pk INTEGER PRIMARY KEY NOT NULL,
8997- id TEXT NOT NULL UNIQUE,
8998- value JSON NOT NULL CHECK (json_type(value) = 'object'),
8999- created INTEGER NOT NULL DEFAULT (unixepoch()),
9000- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
9001- );
9002-
9003- CREATE TABLE IF NOT EXISTS list_settings_json (
9004- pk INTEGER PRIMARY KEY NOT NULL,
9005- name TEXT NOT NULL,
9006- list INTEGER,
9007- value JSON NOT NULL CHECK (json_type(value) = 'object'),
9008- is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
9009- created INTEGER NOT NULL DEFAULT (unixepoch()),
9010- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
9011- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
9012- FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
9013- UNIQUE (list, name) ON CONFLICT ROLLBACK
9014- );
9015-
9016- CREATE TRIGGER
9017- IF NOT EXISTS is_valid_settings_json_on_update
9018- AFTER UPDATE OF value, name, is_valid ON list_settings_json
9019- FOR EACH ROW
9020- BEGIN
9021- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
9022- UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
9023- END;
9024-
9025- CREATE TRIGGER
9026- IF NOT EXISTS is_valid_settings_json_on_insert
9027- AFTER INSERT ON list_settings_json
9028- FOR EACH ROW
9029- BEGIN
9030- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
9031- UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
9032- END;
9033-
9034- CREATE TRIGGER
9035- IF NOT EXISTS invalidate_settings_json_on_schema_update
9036- AFTER UPDATE OF value, id ON settings_json_schema
9037- FOR EACH ROW
9038- BEGIN
9039- UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
9040- END;
9041-
9042- DROP TRIGGER IF EXISTS last_modified_list;
9043- DROP TRIGGER IF EXISTS last_modified_owner;
9044- DROP TRIGGER IF EXISTS last_modified_post_policy;
9045- DROP TRIGGER IF EXISTS last_modified_subscription_policy;
9046- DROP TRIGGER IF EXISTS last_modified_subscription;
9047- DROP TRIGGER IF EXISTS last_modified_account;
9048- DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
9049- DROP TRIGGER IF EXISTS last_modified_template;
9050- DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
9051- DROP TRIGGER IF EXISTS last_modified_list_settings_json;
9052-
9053- -- [tag:last_modified_list]: update last_modified on every change.
9054- CREATE TRIGGER
9055- IF NOT EXISTS last_modified_list
9056- AFTER UPDATE ON list
9057- FOR EACH ROW
9058- WHEN NEW.last_modified == OLD.last_modified
9059- BEGIN
9060- UPDATE list SET last_modified = unixepoch()
9061- WHERE pk = NEW.pk;
9062- END;
9063-
9064- -- [tag:last_modified_owner]: update last_modified on every change.
9065- CREATE TRIGGER
9066- IF NOT EXISTS last_modified_owner
9067- AFTER UPDATE ON owner
9068- FOR EACH ROW
9069- WHEN NEW.last_modified == OLD.last_modified
9070- BEGIN
9071- UPDATE owner SET last_modified = unixepoch()
9072- WHERE pk = NEW.pk;
9073- END;
9074-
9075- -- [tag:last_modified_post_policy]: update last_modified on every change.
9076- CREATE TRIGGER
9077- IF NOT EXISTS last_modified_post_policy
9078- AFTER UPDATE ON post_policy
9079- FOR EACH ROW
9080- WHEN NEW.last_modified == OLD.last_modified
9081- BEGIN
9082- UPDATE post_policy SET last_modified = unixepoch()
9083- WHERE pk = NEW.pk;
9084- END;
9085-
9086- -- [tag:last_modified_subscription_policy]: update last_modified on every change.
9087- CREATE TRIGGER
9088- IF NOT EXISTS last_modified_subscription_policy
9089- AFTER UPDATE ON subscription_policy
9090- FOR EACH ROW
9091- WHEN NEW.last_modified == OLD.last_modified
9092- BEGIN
9093- UPDATE subscription_policy SET last_modified = unixepoch()
9094- WHERE pk = NEW.pk;
9095- END;
9096-
9097- -- [tag:last_modified_subscription]: update last_modified on every change.
9098- CREATE TRIGGER
9099- IF NOT EXISTS last_modified_subscription
9100- AFTER UPDATE ON subscription
9101- FOR EACH ROW
9102- WHEN NEW.last_modified == OLD.last_modified
9103- BEGIN
9104- UPDATE subscription SET last_modified = unixepoch()
9105- WHERE pk = NEW.pk;
9106- END;
9107-
9108- -- [tag:last_modified_account]: update last_modified on every change.
9109- CREATE TRIGGER
9110- IF NOT EXISTS last_modified_account
9111- AFTER UPDATE ON account
9112- FOR EACH ROW
9113- WHEN NEW.last_modified == OLD.last_modified
9114- BEGIN
9115- UPDATE account SET last_modified = unixepoch()
9116- WHERE pk = NEW.pk;
9117- END;
9118-
9119- -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
9120- CREATE TRIGGER
9121- IF NOT EXISTS last_modified_candidate_subscription
9122- AFTER UPDATE ON candidate_subscription
9123- FOR EACH ROW
9124- WHEN NEW.last_modified == OLD.last_modified
9125- BEGIN
9126- UPDATE candidate_subscription SET last_modified = unixepoch()
9127- WHERE pk = NEW.pk;
9128- END;
9129-
9130- -- [tag:last_modified_template]: update last_modified on every change.
9131- CREATE TRIGGER
9132- IF NOT EXISTS last_modified_template
9133- AFTER UPDATE ON template
9134- FOR EACH ROW
9135- WHEN NEW.last_modified == OLD.last_modified
9136- BEGIN
9137- UPDATE template SET last_modified = unixepoch()
9138- WHERE pk = NEW.pk;
9139- END;
9140-
9141- -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
9142- CREATE TRIGGER
9143- IF NOT EXISTS last_modified_settings_json_schema
9144- AFTER UPDATE ON settings_json_schema
9145- FOR EACH ROW
9146- WHEN NEW.last_modified == OLD.last_modified
9147- BEGIN
9148- UPDATE settings_json_schema SET last_modified = unixepoch()
9149- WHERE pk = NEW.pk;
9150- END;
9151-
9152- -- [tag:last_modified_list_settings_json]: update last_modified on every change.
9153- CREATE TRIGGER
9154- IF NOT EXISTS last_modified_list_settings_json
9155- AFTER UPDATE ON list_settings_json
9156- FOR EACH ROW
9157- WHEN NEW.last_modified == OLD.last_modified
9158- BEGIN
9159- UPDATE list_settings_json SET last_modified = unixepoch()
9160- WHERE pk = NEW.pk;
9161- END;"##,r##"DROP TABLE settings_json_schema;
9162- DROP TABLE list_settings_json;"##),(5,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
9163- "$schema": "http://json-schema.org/draft-07/schema",
9164- "$ref": "#/$defs/ArchivedAtLinkSettings",
9165- "$defs": {
9166- "ArchivedAtLinkSettings": {
9167- "title": "ArchivedAtLinkSettings",
9168- "description": "Settings for ArchivedAtLink message filter",
9169- "type": "object",
9170- "properties": {
9171- "template": {
9172- "title": "Jinja template for header value",
9173- "description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
9174- "examples": [
9175- "https://www.example.com/{{msg_id}}",
9176- "https://www.example.com/{{msg_id}}.html"
9177- ],
9178- "type": "string",
9179- "pattern": ".+[{][{]msg_id[}][}].*"
9180- },
9181- "preserve_carets": {
9182- "title": "Preserve carets of `Message-ID` in generated value",
9183- "type": "boolean",
9184- "default": false
9185- }
9186- },
9187- "required": [
9188- "template"
9189- ]
9190- }
9191- }
9192- }');"##,r##"DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';"##),(6,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
9193- "$schema": "http://json-schema.org/draft-07/schema",
9194- "$ref": "#/$defs/AddSubjectTagPrefixSettings",
9195- "$defs": {
9196- "AddSubjectTagPrefixSettings": {
9197- "title": "AddSubjectTagPrefixSettings",
9198- "description": "Settings for AddSubjectTagPrefix message filter",
9199- "type": "object",
9200- "properties": {
9201- "enabled": {
9202- "title": "If true, the list subject prefix is added to post subjects.",
9203- "type": "boolean"
9204- }
9205- },
9206- "required": [
9207- "enabled"
9208- ]
9209- }
9210- }
9211- }');"##,r##"DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';"##),(7,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
9212- "$schema": "http://json-schema.org/draft-07/schema",
9213- "$ref": "#/$defs/MimeRejectSettings",
9214- "$defs": {
9215- "MimeRejectSettings": {
9216- "title": "MimeRejectSettings",
9217- "description": "Settings for MimeReject message filter",
9218- "type": "object",
9219- "properties": {
9220- "enabled": {
9221- "title": "If true, list posts that contain mime types in the reject array are rejected.",
9222- "type": "boolean"
9223- },
9224- "reject": {
9225- "title": "Mime types to reject.",
9226- "type": "array",
9227- "minLength": 0,
9228- "items": { "$ref": "#/$defs/MimeType" }
9229- },
9230- "required": [
9231- "enabled"
9232- ]
9233- }
9234- },
9235- "MimeType": {
9236- "type": "string",
9237- "maxLength": 127,
9238- "minLength": 3,
9239- "uniqueItems": true,
9240- "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
9241- }
9242- }
9243- }');"##,r##"DELETE FROM settings_json_schema WHERE id = 'MimeRejectSettings';"##),]
9244\ No newline at end of file
9245 diff --git a/core/src/models.rs b/core/src/models.rs
9246deleted file mode 100644
9247index 884c966..0000000
9248--- a/core/src/models.rs
9249+++ /dev/null
9250 @@ -1,746 +0,0 @@
9251- /*
9252- * This file is part of mailpot
9253- *
9254- * Copyright 2020 - Manos Pitsidianakis
9255- *
9256- * This program is free software: you can redistribute it and/or modify
9257- * it under the terms of the GNU Affero General Public License as
9258- * published by the Free Software Foundation, either version 3 of the
9259- * License, or (at your option) any later version.
9260- *
9261- * This program is distributed in the hope that it will be useful,
9262- * but WITHOUT ANY WARRANTY; without even the implied warranty of
9263- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9264- * GNU Affero General Public License for more details.
9265- *
9266- * You should have received a copy of the GNU Affero General Public License
9267- * along with this program. If not, see <https://www.gnu.org/licenses/>.
9268- */
9269-
9270- //! Database models: [`MailingList`], [`ListOwner`], [`ListSubscription`],
9271- //! [`PostPolicy`], [`SubscriptionPolicy`] and [`Post`].
9272-
9273- use super::*;
9274- pub mod changesets;
9275-
9276- use std::borrow::Cow;
9277-
9278- use melib::email::Address;
9279-
9280- /// A database entry and its primary key. Derefs to its inner type.
9281- ///
9282- /// # Example
9283- ///
9284- /// ```rust,no_run
9285- /// # use mailpot::{*, models::*};
9286- /// # fn foo(db: &Connection) {
9287- /// let val: Option<DbVal<MailingList>> = db.list(5).unwrap();
9288- /// if let Some(list) = val {
9289- /// assert_eq!(list.pk(), 5);
9290- /// }
9291- /// # }
9292- /// ```
9293- #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
9294- #[serde(transparent)]
9295- pub struct DbVal<T: Send + Sync>(pub T, #[serde(skip)] pub i64);
9296-
9297- impl<T: Send + Sync> DbVal<T> {
9298- /// Primary key.
9299- #[inline(always)]
9300- pub fn pk(&self) -> i64 {
9301- self.1
9302- }
9303-
9304- /// Unwrap inner value.
9305- #[inline(always)]
9306- pub fn into_inner(self) -> T {
9307- self.0
9308- }
9309- }
9310-
9311- impl<T> std::borrow::Borrow<T> for DbVal<T>
9312- where
9313- T: Send + Sync + Sized,
9314- {
9315- fn borrow(&self) -> &T {
9316- &self.0
9317- }
9318- }
9319-
9320- impl<T> std::ops::Deref for DbVal<T>
9321- where
9322- T: Send + Sync,
9323- {
9324- type Target = T;
9325- fn deref(&self) -> &T {
9326- &self.0
9327- }
9328- }
9329-
9330- impl<T> std::ops::DerefMut for DbVal<T>
9331- where
9332- T: Send + Sync,
9333- {
9334- fn deref_mut(&mut self) -> &mut Self::Target {
9335- &mut self.0
9336- }
9337- }
9338-
9339- impl<T> std::fmt::Display for DbVal<T>
9340- where
9341- T: std::fmt::Display + Send + Sync,
9342- {
9343- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9344- write!(fmt, "{}", self.0)
9345- }
9346- }
9347-
9348- /// A mailing list entry.
9349- #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
9350- pub struct MailingList {
9351- /// Database primary key.
9352- pub pk: i64,
9353- /// Mailing list name.
9354- pub name: String,
9355- /// Mailing list ID (what appears in the subject tag, e.g. `[mailing-list]
9356- /// New post!`).
9357- pub id: String,
9358- /// Mailing list e-mail address.
9359- pub address: String,
9360- /// Discussion topics.
9361- pub topics: Vec<String>,
9362- /// Mailing list description.
9363- pub description: Option<String>,
9364- /// Mailing list archive URL.
9365- pub archive_url: Option<String>,
9366- }
9367-
9368- impl std::fmt::Display for MailingList {
9369- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9370- if let Some(description) = self.description.as_ref() {
9371- write!(
9372- fmt,
9373- "[#{} {}] {} <{}>: {}",
9374- self.pk, self.id, self.name, self.address, description
9375- )
9376- } else {
9377- write!(
9378- fmt,
9379- "[#{} {}] {} <{}>",
9380- self.pk, self.id, self.name, self.address
9381- )
9382- }
9383- }
9384- }
9385-
9386- impl MailingList {
9387- /// Mailing list display name.
9388- ///
9389- /// # Example
9390- ///
9391- /// ```rust
9392- /// # fn main() -> mailpot::Result<()> {
9393- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9394- /// assert_eq!(
9395- /// &list.display_name(),
9396- /// "\"foobar chat\" <foo-chat@example.com>"
9397- /// );
9398- /// # Ok(())
9399- /// # }
9400- pub fn display_name(&self) -> String {
9401- format!("\"{}\" <{}>", self.name, self.address)
9402- }
9403-
9404- #[inline]
9405- /// Request subaddress.
9406- ///
9407- /// # Example
9408- ///
9409- /// ```rust
9410- /// # fn main() -> mailpot::Result<()> {
9411- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9412- /// assert_eq!(&list.request_subaddr(), "foo-chat+request@example.com");
9413- /// # Ok(())
9414- /// # }
9415- pub fn request_subaddr(&self) -> String {
9416- let p = self.address.split('@').collect::<Vec<&str>>();
9417- format!("{}+request@{}", p[0], p[1])
9418- }
9419-
9420- /// Value of `List-Id` header.
9421- ///
9422- /// See RFC2919 Section 3: <https://www.rfc-editor.org/rfc/rfc2919>
9423- ///
9424- /// # Example
9425- ///
9426- /// ```rust
9427- /// # fn main() -> mailpot::Result<()> {
9428- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9429- /// assert_eq!(
9430- /// &list.id_header(),
9431- /// "Hello world, from foo-chat list <foo-chat.example.com>");
9432- /// # Ok(())
9433- /// # }
9434- pub fn id_header(&self) -> String {
9435- let p = self.address.split('@').collect::<Vec<&str>>();
9436- format!(
9437- "{}{}<{}.{}>",
9438- self.description.as_deref().unwrap_or(""),
9439- self.description.as_ref().map(|_| " ").unwrap_or(""),
9440- self.id,
9441- p[1]
9442- )
9443- }
9444-
9445- /// Value of `List-Help` header.
9446- ///
9447- /// See RFC2369 Section 3.1: <https://www.rfc-editor.org/rfc/rfc2369#section-3.1>
9448- ///
9449- /// # Example
9450- ///
9451- /// ```rust
9452- /// # fn main() -> mailpot::Result<()> {
9453- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9454- /// assert_eq!(
9455- /// &list.help_header().unwrap(),
9456- /// "<mailto:foo-chat+request@example.com?subject=help>"
9457- /// );
9458- /// # Ok(())
9459- /// # }
9460- pub fn help_header(&self) -> Option<String> {
9461- Some(format!("<mailto:{}?subject=help>", self.request_subaddr()))
9462- }
9463-
9464- /// Value of `List-Post` header.
9465- ///
9466- /// See RFC2369 Section 3.4: <https://www.rfc-editor.org/rfc/rfc2369#section-3.4>
9467- ///
9468- /// # Example
9469- ///
9470- /// ```rust
9471- /// # fn main() -> mailpot::Result<()> {
9472- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9473- /// assert_eq!(&list.post_header(None).unwrap(), "NO");
9474- /// assert_eq!(
9475- /// &list.post_header(Some(&post_policy)).unwrap(),
9476- /// "<mailto:foo-chat@example.com>"
9477- /// );
9478- /// # Ok(())
9479- /// # }
9480- pub fn post_header(&self, policy: Option<&PostPolicy>) -> Option<String> {
9481- Some(policy.map_or_else(
9482- || "NO".to_string(),
9483- |p| {
9484- if p.announce_only {
9485- "NO".to_string()
9486- } else {
9487- format!("<mailto:{}>", self.address)
9488- }
9489- },
9490- ))
9491- }
9492-
9493- /// Value of `List-Unsubscribe` header.
9494- ///
9495- /// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
9496- ///
9497- /// # Example
9498- ///
9499- /// ```rust
9500- /// # fn main() -> mailpot::Result<()> {
9501- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9502- /// assert_eq!(
9503- /// &list.unsubscribe_header(Some(&sub_policy)).unwrap(),
9504- /// "<mailto:foo-chat+request@example.com?subject=unsubscribe>"
9505- /// );
9506- /// # Ok(())
9507- /// # }
9508- pub fn unsubscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
9509- policy.map_or_else(
9510- || None,
9511- |_| {
9512- Some(format!(
9513- "<mailto:{}?subject=unsubscribe>",
9514- self.request_subaddr()
9515- ))
9516- },
9517- )
9518- }
9519-
9520- /// Value of `List-Subscribe` header.
9521- ///
9522- /// See RFC2369 Section 3.3: <https://www.rfc-editor.org/rfc/rfc2369#section-3.3>
9523- ///
9524- /// # Example
9525- ///
9526- /// ```rust
9527- /// # fn main() -> mailpot::Result<()> {
9528- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9529- /// assert_eq!(
9530- /// &list.subscribe_header(Some(&sub_policy)).unwrap(),
9531- /// "<mailto:foo-chat+request@example.com?subject=subscribe>",
9532- /// );
9533- /// # Ok(())
9534- /// # }
9535- /// ```
9536- pub fn subscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
9537- policy.map_or_else(
9538- || None,
9539- |_| {
9540- Some(format!(
9541- "<mailto:{}?subject=subscribe>",
9542- self.request_subaddr()
9543- ))
9544- },
9545- )
9546- }
9547-
9548- /// Value of `List-Archive` header.
9549- ///
9550- /// See RFC2369 Section 3.6: <https://www.rfc-editor.org/rfc/rfc2369#section-3.6>
9551- ///
9552- /// # Example
9553- ///
9554- /// ```rust
9555- /// # fn main() -> mailpot::Result<()> {
9556- #[doc = include_str!("./doctests/db_setup.rs.inc")]
9557- /// assert_eq!(
9558- /// &list.archive_header().unwrap(),
9559- /// "<https://lists.example.com>"
9560- /// );
9561- /// # Ok(())
9562- /// # }
9563- /// ```
9564- pub fn archive_header(&self) -> Option<String> {
9565- self.archive_url.as_ref().map(|url| format!("<{}>", url))
9566- }
9567-
9568- /// List address as a [`melib::Address`]
9569- pub fn address(&self) -> Address {
9570- Address::new(Some(self.name.clone()), self.address.clone())
9571- }
9572-
9573- /// List unsubscribe action as a [`MailtoAddress`].
9574- pub fn unsubscription_mailto(&self) -> MailtoAddress {
9575- MailtoAddress {
9576- address: self.request_subaddr(),
9577- subject: Some("unsubscribe".to_string()),
9578- }
9579- }
9580-
9581- /// List subscribe action as a [`MailtoAddress`].
9582- pub fn subscription_mailto(&self) -> MailtoAddress {
9583- MailtoAddress {
9584- address: self.request_subaddr(),
9585- subject: Some("subscribe".to_string()),
9586- }
9587- }
9588-
9589- /// List owner as a [`MailtoAddress`].
9590- pub fn owner_mailto(&self) -> MailtoAddress {
9591- let p = self.address.split('@').collect::<Vec<&str>>();
9592- MailtoAddress {
9593- address: format!("{}+owner@{}", p[0], p[1]),
9594- subject: None,
9595- }
9596- }
9597-
9598- /// List archive url value.
9599- pub fn archive_url(&self) -> Option<&str> {
9600- self.archive_url.as_deref()
9601- }
9602-
9603- /// Insert all available list headers.
9604- pub fn insert_headers(
9605- &self,
9606- draft: &mut melib::Draft,
9607- post_policy: Option<&PostPolicy>,
9608- subscription_policy: Option<&SubscriptionPolicy>,
9609- ) {
9610- for (hdr, val) in [
9611- ("List-Id", Some(self.id_header())),
9612- ("List-Help", self.help_header()),
9613- ("List-Post", self.post_header(post_policy)),
9614- (
9615- "List-Unsubscribe",
9616- self.unsubscribe_header(subscription_policy),
9617- ),
9618- ("List-Subscribe", self.subscribe_header(subscription_policy)),
9619- ("List-Archive", self.archive_header()),
9620- ] {
9621- if let Some(val) = val {
9622- draft
9623- .headers
9624- .insert(melib::HeaderName::try_from(hdr).unwrap(), val);
9625- }
9626- }
9627- }
9628-
9629- /// Generate help e-mail body containing information on how to subscribe,
9630- /// unsubscribe, post and how to contact the list owners.
9631- pub fn generate_help_email(
9632- &self,
9633- post_policy: Option<&PostPolicy>,
9634- subscription_policy: Option<&SubscriptionPolicy>,
9635- ) -> String {
9636- format!(
9637- "Help for {list_name}\n\n{subscribe}\n\n{post}\n\nTo contact the list owners, send an \
9638- e-mail to {contact}\n",
9639- list_name = self.name,
9640- subscribe = subscription_policy.map_or(
9641- Cow::Borrowed("This list is not open to subscriptions."),
9642- |p| if p.open {
9643- Cow::Owned(format!(
9644- "Anyone can subscribe without restrictions. Send an e-mail to {} with the \
9645- subject `subscribe`.",
9646- self.request_subaddr(),
9647- ))
9648- } else if p.manual {
9649- Cow::Borrowed(
9650- "The list owners must manually add you to the list of subscriptions.",
9651- )
9652- } else if p.request {
9653- Cow::Owned(format!(
9654- "Anyone can request to subscribe. Send an e-mail to {} with the subject \
9655- `subscribe` and a confirmation will be sent to you when your request is \
9656- approved.",
9657- self.request_subaddr(),
9658- ))
9659- } else {
9660- Cow::Borrowed("Please contact the list owners for details on how to subscribe.")
9661- }
9662- ),
9663- post = post_policy.map_or(Cow::Borrowed("This list does not allow posting."), |p| {
9664- if p.announce_only {
9665- Cow::Borrowed(
9666- "This list is announce only, which means that you can only receive posts \
9667- from the list owners.",
9668- )
9669- } else if p.subscription_only {
9670- Cow::Owned(format!(
9671- "Only list subscriptions can post to this list. Send your post to {}",
9672- self.address
9673- ))
9674- } else if p.approval_needed {
9675- Cow::Owned(format!(
9676- "Anyone can post, but approval from list owners is required if they are \
9677- not subscribed. Send your post to {}",
9678- self.address
9679- ))
9680- } else {
9681- Cow::Borrowed("This list does not allow posting.")
9682- }
9683- }),
9684- contact = self.owner_mailto().address,
9685- )
9686- }
9687-
9688- /// Utility function to get a `Vec<String>` -which is the expected type of
9689- /// the `topics` field- from a `serde_json::Value`, which is the value
9690- /// stored in the `topics` column in `sqlite3`.
9691- ///
9692- /// # Example
9693- ///
9694- /// ```rust
9695- /// # use mailpot::models::MailingList;
9696- /// use serde_json::Value;
9697- ///
9698- /// # fn main() -> Result<(), serde_json::Error> {
9699- /// let value: Value = serde_json::from_str(r#"["fruits","vegetables"]"#)?;
9700- /// assert_eq!(
9701- /// MailingList::topics_from_json_value(value),
9702- /// Ok(vec!["fruits".to_string(), "vegetables".to_string()])
9703- /// );
9704- ///
9705- /// let value: Value = serde_json::from_str(r#"{"invalid":"value"}"#)?;
9706- /// assert!(MailingList::topics_from_json_value(value).is_err());
9707- /// # Ok(())
9708- /// # }
9709- /// ```
9710- pub fn topics_from_json_value(
9711- v: serde_json::Value,
9712- ) -> std::result::Result<Vec<String>, rusqlite::Error> {
9713- let err_fn = || {
9714- rusqlite::Error::FromSqlConversionFailure(
9715- 8,
9716- rusqlite::types::Type::Text,
9717- anyhow::Error::msg(
9718- "topics column must be a json array of strings serialized as a string, e.g. \
9719- \"[]\" or \"['topicA', 'topicB']\"",
9720- )
9721- .into(),
9722- )
9723- };
9724- v.as_array()
9725- .map(|arr| {
9726- arr.iter()
9727- .map(|v| v.as_str().map(str::to_string))
9728- .collect::<Option<Vec<String>>>()
9729- })
9730- .ok_or_else(err_fn)?
9731- .ok_or_else(err_fn)
9732- }
9733- }
9734-
9735- /// A mailing list subscription entry.
9736- #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
9737- pub struct ListSubscription {
9738- /// Database primary key.
9739- pub pk: i64,
9740- /// Mailing list foreign key (See [`MailingList`]).
9741- pub list: i64,
9742- /// Subscription's e-mail address.
9743- pub address: String,
9744- /// Subscription's name, optional.
9745- pub name: Option<String>,
9746- /// Subscription's account foreign key, optional.
9747- pub account: Option<i64>,
9748- /// Whether this subscription is enabled.
9749- pub enabled: bool,
9750- /// Whether the e-mail address is verified.
9751- pub verified: bool,
9752- /// Whether subscription wishes to receive list posts as a periodical digest
9753- /// e-mail.
9754- pub digest: bool,
9755- /// Whether subscription wishes their e-mail address hidden from public
9756- /// view.
9757- pub hide_address: bool,
9758- /// Whether subscription wishes to receive mailing list post duplicates,
9759- /// i.e. posts addressed to them and the mailing list to which they are
9760- /// subscribed.
9761- pub receive_duplicates: bool,
9762- /// Whether subscription wishes to receive their own mailing list posts from
9763- /// the mailing list, as a confirmation.
9764- pub receive_own_posts: bool,
9765- /// Whether subscription wishes to receive a plain confirmation for their
9766- /// own mailing list posts.
9767- pub receive_confirmation: bool,
9768- }
9769-
9770- impl std::fmt::Display for ListSubscription {
9771- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9772- write!(
9773- fmt,
9774- "{} [digest: {}, hide_address: {} verified: {} {}]",
9775- self.address(),
9776- self.digest,
9777- self.hide_address,
9778- self.verified,
9779- if self.enabled {
9780- "enabled"
9781- } else {
9782- "not enabled"
9783- },
9784- )
9785- }
9786- }
9787-
9788- impl ListSubscription {
9789- /// Subscription address as a [`melib::Address`]
9790- pub fn address(&self) -> Address {
9791- Address::new(self.name.clone(), self.address.clone())
9792- }
9793- }
9794-
9795- /// A mailing list post policy entry.
9796- ///
9797- /// Only one of the boolean flags must be set to true.
9798- #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
9799- pub struct PostPolicy {
9800- /// Database primary key.
9801- pub pk: i64,
9802- /// Mailing list foreign key (See [`MailingList`]).
9803- pub list: i64,
9804- /// Whether the policy is announce only (Only list owners can submit posts,
9805- /// and everyone will receive them).
9806- pub announce_only: bool,
9807- /// Whether the policy is "subscription only" (Only list subscriptions can
9808- /// post).
9809- pub subscription_only: bool,
9810- /// Whether the policy is "approval needed" (Anyone can post, but approval
9811- /// from list owners is required if they are not subscribed).
9812- pub approval_needed: bool,
9813- /// Whether the policy is "open" (Anyone can post, but approval from list
9814- /// owners is required. Subscriptions are not enabled).
9815- pub open: bool,
9816- /// Custom policy.
9817- pub custom: bool,
9818- }
9819-
9820- impl std::fmt::Display for PostPolicy {
9821- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9822- write!(fmt, "{:?}", self)
9823- }
9824- }
9825-
9826- /// A mailing list owner entry.
9827- #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
9828- pub struct ListOwner {
9829- /// Database primary key.
9830- pub pk: i64,
9831- /// Mailing list foreign key (See [`MailingList`]).
9832- pub list: i64,
9833- /// Mailing list owner e-mail address.
9834- pub address: String,
9835- /// Mailing list owner name, optional.
9836- pub name: Option<String>,
9837- }
9838-
9839- impl std::fmt::Display for ListOwner {
9840- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9841- write!(fmt, "[#{} {}] {}", self.pk, self.list, self.address())
9842- }
9843- }
9844-
9845- impl From<ListOwner> for ListSubscription {
9846- fn from(val: ListOwner) -> Self {
9847- Self {
9848- pk: 0,
9849- list: val.list,
9850- address: val.address,
9851- name: val.name,
9852- account: None,
9853- digest: false,
9854- hide_address: false,
9855- receive_duplicates: true,
9856- receive_own_posts: false,
9857- receive_confirmation: true,
9858- enabled: true,
9859- verified: true,
9860- }
9861- }
9862- }
9863-
9864- impl ListOwner {
9865- /// Owner address as a [`melib::Address`]
9866- pub fn address(&self) -> Address {
9867- Address::new(self.name.clone(), self.address.clone())
9868- }
9869- }
9870-
9871- /// A mailing list post entry.
9872- #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
9873- pub struct Post {
9874- /// Database primary key.
9875- pub pk: i64,
9876- /// Mailing list foreign key (See [`MailingList`]).
9877- pub list: i64,
9878- /// Envelope `From` of post.
9879- pub envelope_from: Option<String>,
9880- /// `From` header address of post.
9881- pub address: String,
9882- /// `Message-ID` header value of post.
9883- pub message_id: String,
9884- /// Post as bytes.
9885- pub message: Vec<u8>,
9886- /// Unix timestamp of date.
9887- pub timestamp: u64,
9888- /// Date header as string.
9889- pub datetime: String,
9890- /// Month-year as a `YYYY-mm` formatted string, for use in archives.
9891- pub month_year: String,
9892- }
9893-
9894- impl std::fmt::Debug for Post {
9895- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9896- fmt.debug_struct(stringify!(Post))
9897- .field("pk", &self.pk)
9898- .field("list", &self.list)
9899- .field("envelope_from", &self.envelope_from)
9900- .field("address", &self.address)
9901- .field("message_id", &self.message_id)
9902- .field("message", &String::from_utf8_lossy(&self.message))
9903- .field("timestamp", &self.timestamp)
9904- .field("datetime", &self.datetime)
9905- .field("month_year", &self.month_year)
9906- .finish()
9907- }
9908- }
9909-
9910- impl std::fmt::Display for Post {
9911- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9912- write!(fmt, "{:?}", self)
9913- }
9914- }
9915-
9916- /// A mailing list subscription policy entry.
9917- ///
9918- /// Only one of the policy boolean flags must be set to true.
9919- #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
9920- pub struct SubscriptionPolicy {
9921- /// Database primary key.
9922- pub pk: i64,
9923- /// Mailing list foreign key (See [`MailingList`]).
9924- pub list: i64,
9925- /// Send confirmation e-mail when subscription is finalized.
9926- pub send_confirmation: bool,
9927- /// Anyone can subscribe without restrictions.
9928- pub open: bool,
9929- /// Only list owners can manually add subscriptions.
9930- pub manual: bool,
9931- /// Anyone can request to subscribe.
9932- pub request: bool,
9933- /// Allow subscriptions, but handle it manually.
9934- pub custom: bool,
9935- }
9936-
9937- impl std::fmt::Display for SubscriptionPolicy {
9938- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9939- write!(fmt, "{:?}", self)
9940- }
9941- }
9942-
9943- /// An account entry.
9944- #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
9945- pub struct Account {
9946- /// Database primary key.
9947- pub pk: i64,
9948- /// Accounts's display name, optional.
9949- pub name: Option<String>,
9950- /// Account's e-mail address.
9951- pub address: String,
9952- /// GPG public key.
9953- pub public_key: Option<String>,
9954- /// SSH public key.
9955- pub password: String,
9956- /// Whether this account is enabled.
9957- pub enabled: bool,
9958- }
9959-
9960- impl std::fmt::Display for Account {
9961- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9962- write!(fmt, "{:?}", self)
9963- }
9964- }
9965-
9966- /// A mailing list subscription candidate.
9967- #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
9968- pub struct ListCandidateSubscription {
9969- /// Database primary key.
9970- pub pk: i64,
9971- /// Mailing list foreign key (See [`MailingList`]).
9972- pub list: i64,
9973- /// Subscription's e-mail address.
9974- pub address: String,
9975- /// Subscription's name, optional.
9976- pub name: Option<String>,
9977- /// Accepted, foreign key on [`ListSubscription`].
9978- pub accepted: Option<i64>,
9979- }
9980-
9981- impl ListCandidateSubscription {
9982- /// Subscription request address as a [`melib::Address`]
9983- pub fn address(&self) -> Address {
9984- Address::new(self.name.clone(), self.address.clone())
9985- }
9986- }
9987-
9988- impl std::fmt::Display for ListCandidateSubscription {
9989- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
9990- write!(
9991- fmt,
9992- "List_pk: {} name: {:?} address: {} accepted: {:?}",
9993- self.list, self.name, self.address, self.accepted,
9994- )
9995- }
9996- }
9997 diff --git a/core/src/models/changesets.rs b/core/src/models/changesets.rs
9998deleted file mode 100644
9999index 93ab14e..0000000
10000--- a/core/src/models/changesets.rs
10001+++ /dev/null
10002 @@ -1,120 +0,0 @@
10003- /*
10004- * This file is part of mailpot
10005- *
10006- * Copyright 2020 - Manos Pitsidianakis
10007- *
10008- * This program is free software: you can redistribute it and/or modify
10009- * it under the terms of the GNU Affero General Public License as
10010- * published by the Free Software Foundation, either version 3 of the
10011- * License, or (at your option) any later version.
10012- *
10013- * This program is distributed in the hope that it will be useful,
10014- * but WITHOUT ANY WARRANTY; without even the implied warranty of
10015- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10016- * GNU Affero General Public License for more details.
10017- *
10018- * You should have received a copy of the GNU Affero General Public License
10019- * along with this program. If not, see <https://www.gnu.org/licenses/>.
10020- */
10021-
10022- //! Changeset structs: update specific struct fields.
10023-
10024- macro_rules! impl_display {
10025- ($t:ty) => {
10026- impl std::fmt::Display for $t {
10027- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
10028- write!(fmt, "{:?}", self)
10029- }
10030- }
10031- };
10032- }
10033-
10034- /// Changeset struct for [`Mailinglist`](super::MailingList).
10035- #[derive(Default, Debug, Clone, Deserialize, Serialize)]
10036- pub struct MailingListChangeset {
10037- /// Database primary key.
10038- pub pk: i64,
10039- /// Optional new value.
10040- pub name: Option<String>,
10041- /// Optional new value.
10042- pub id: Option<String>,
10043- /// Optional new value.
10044- pub address: Option<String>,
10045- /// Optional new value.
10046- pub description: Option<Option<String>>,
10047- /// Optional new value.
10048- pub archive_url: Option<Option<String>>,
10049- /// Optional new value.
10050- pub owner_local_part: Option<Option<String>>,
10051- /// Optional new value.
10052- pub request_local_part: Option<Option<String>>,
10053- /// Optional new value.
10054- pub verify: Option<bool>,
10055- /// Optional new value.
10056- pub hidden: Option<bool>,
10057- /// Optional new value.
10058- pub enabled: Option<bool>,
10059- }
10060-
10061- impl_display!(MailingListChangeset);
10062-
10063- /// Changeset struct for [`ListSubscription`](super::ListSubscription).
10064- #[derive(Default, Debug, Clone, Deserialize, Serialize)]
10065- pub struct ListSubscriptionChangeset {
10066- /// Mailing list foreign key (See [`MailingList`](super::MailingList)).
10067- pub list: i64,
10068- /// Subscription e-mail address.
10069- pub address: String,
10070- /// Optional new value.
10071- pub account: Option<Option<i64>>,
10072- /// Optional new value.
10073- pub name: Option<Option<String>>,
10074- /// Optional new value.
10075- pub digest: Option<bool>,
10076- /// Optional new value.
10077- pub enabled: Option<bool>,
10078- /// Optional new value.
10079- pub verified: Option<bool>,
10080- /// Optional new value.
10081- pub hide_address: Option<bool>,
10082- /// Optional new value.
10083- pub receive_duplicates: Option<bool>,
10084- /// Optional new value.
10085- pub receive_own_posts: Option<bool>,
10086- /// Optional new value.
10087- pub receive_confirmation: Option<bool>,
10088- }
10089-
10090- impl_display!(ListSubscriptionChangeset);
10091-
10092- /// Changeset struct for [`ListOwner`](super::ListOwner).
10093- #[derive(Default, Debug, Clone, Deserialize, Serialize)]
10094- pub struct ListOwnerChangeset {
10095- /// Database primary key.
10096- pub pk: i64,
10097- /// Mailing list foreign key (See [`MailingList`](super::MailingList)).
10098- pub list: i64,
10099- /// Optional new value.
10100- pub address: Option<String>,
10101- /// Optional new value.
10102- pub name: Option<Option<String>>,
10103- }
10104-
10105- impl_display!(ListOwnerChangeset);
10106-
10107- /// Changeset struct for [`Account`](super::Account).
10108- #[derive(Default, Debug, Clone, Deserialize, Serialize)]
10109- pub struct AccountChangeset {
10110- /// Account e-mail address.
10111- pub address: String,
10112- /// Optional new value.
10113- pub name: Option<Option<String>>,
10114- /// Optional new value.
10115- pub public_key: Option<Option<String>>,
10116- /// Optional new value.
10117- pub password: Option<String>,
10118- /// Optional new value.
10119- pub enabled: Option<Option<bool>>,
10120- }
10121-
10122- impl_display!(AccountChangeset);
10123 diff --git a/core/src/policies.rs b/core/src/policies.rs
10124deleted file mode 100644
10125index 1632653..0000000
10126--- a/core/src/policies.rs
10127+++ /dev/null
10128 @@ -1,404 +0,0 @@
10129- /*
10130- * This file is part of mailpot
10131- *
10132- * Copyright 2020 - Manos Pitsidianakis
10133- *
10134- * This program is free software: you can redistribute it and/or modify
10135- * it under the terms of the GNU Affero General Public License as
10136- * published by the Free Software Foundation, either version 3 of the
10137- * License, or (at your option) any later version.
10138- *
10139- * This program is distributed in the hope that it will be useful,
10140- * but WITHOUT ANY WARRANTY; without even the implied warranty of
10141- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10142- * GNU Affero General Public License for more details.
10143- *
10144- * You should have received a copy of the GNU Affero General Public License
10145- * along with this program. If not, see <https://www.gnu.org/licenses/>.
10146- */
10147-
10148- //! How each list handles new posts and new subscriptions.
10149-
10150- mod post_policy {
10151- use log::trace;
10152- use rusqlite::OptionalExtension;
10153-
10154- use crate::{
10155- errors::{ErrorKind::*, *},
10156- models::{DbVal, PostPolicy},
10157- Connection,
10158- };
10159-
10160- impl Connection {
10161- /// Fetch the post policy of a mailing list.
10162- pub fn list_post_policy(&self, pk: i64) -> Result<Option<DbVal<PostPolicy>>> {
10163- let mut stmt = self
10164- .connection
10165- .prepare("SELECT * FROM post_policy WHERE list = ?;")?;
10166- let ret = stmt
10167- .query_row([&pk], |row| {
10168- let pk = row.get("pk")?;
10169- Ok(DbVal(
10170- PostPolicy {
10171- pk,
10172- list: row.get("list")?,
10173- announce_only: row.get("announce_only")?,
10174- subscription_only: row.get("subscription_only")?,
10175- approval_needed: row.get("approval_needed")?,
10176- open: row.get("open")?,
10177- custom: row.get("custom")?,
10178- },
10179- pk,
10180- ))
10181- })
10182- .optional()?;
10183-
10184- Ok(ret)
10185- }
10186-
10187- /// Remove an existing list policy.
10188- ///
10189- /// # Examples
10190- ///
10191- /// ```
10192- /// # use mailpot::{models::*, Configuration, Connection, SendMail};
10193- /// # use tempfile::TempDir;
10194- /// #
10195- /// # let tmp_dir = TempDir::new().unwrap();
10196- /// # let db_path = tmp_dir.path().join("mpot.db");
10197- /// # let config = Configuration {
10198- /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
10199- /// # db_path: db_path.clone(),
10200- /// # data_path: tmp_dir.path().to_path_buf(),
10201- /// # administrators: vec![],
10202- /// # };
10203- /// #
10204- /// # fn do_test(config: Configuration) {
10205- /// let db = Connection::open_or_create_db(config).unwrap().trusted();
10206- /// # assert!(db.list_post_policy(1).unwrap().is_none());
10207- /// let list = db
10208- /// .create_list(MailingList {
10209- /// pk: 0,
10210- /// name: "foobar chat".into(),
10211- /// id: "foo-chat".into(),
10212- /// address: "foo-chat@example.com".into(),
10213- /// description: None,
10214- /// topics: vec![],
10215- /// archive_url: None,
10216- /// })
10217- /// .unwrap();
10218- ///
10219- /// # assert!(db.list_post_policy(list.pk()).unwrap().is_none());
10220- /// let pol = db
10221- /// .set_list_post_policy(PostPolicy {
10222- /// pk: -1,
10223- /// list: list.pk(),
10224- /// announce_only: false,
10225- /// subscription_only: true,
10226- /// approval_needed: false,
10227- /// open: false,
10228- /// custom: false,
10229- /// })
10230- /// .unwrap();
10231- /// # assert_eq!(db.list_post_policy(list.pk()).unwrap().as_ref(), Some(&pol));
10232- /// db.remove_list_post_policy(list.pk(), pol.pk()).unwrap();
10233- /// # assert!(db.list_post_policy(list.pk()).unwrap().is_none());
10234- /// # }
10235- /// # do_test(config);
10236- /// ```
10237- ///
10238- /// ```should_panic
10239- /// # use mailpot::{models::*, Configuration, Connection, SendMail};
10240- /// # use tempfile::TempDir;
10241- /// #
10242- /// # let tmp_dir = TempDir::new().unwrap();
10243- /// # let db_path = tmp_dir.path().join("mpot.db");
10244- /// # let config = Configuration {
10245- /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
10246- /// # db_path: db_path.clone(),
10247- /// # data_path: tmp_dir.path().to_path_buf(),
10248- /// # administrators: vec![],
10249- /// # };
10250- /// #
10251- /// # fn do_test(config: Configuration) {
10252- /// let db = Connection::open_or_create_db(config).unwrap().trusted();
10253- /// db.remove_list_post_policy(1, 1).unwrap();
10254- /// # }
10255- /// # do_test(config);
10256- /// ```
10257- pub fn remove_list_post_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
10258- let mut stmt = self
10259- .connection
10260- .prepare("DELETE FROM post_policy WHERE pk = ? AND list = ? RETURNING *;")?;
10261- stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
10262- .map_err(|err| {
10263- if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
10264- Error::from(err).chain_err(|| NotFound("list or list policy not found!"))
10265- } else {
10266- err.into()
10267- }
10268- })?;
10269-
10270- trace!("remove_list_post_policy {} {}.", list_pk, policy_pk);
10271- Ok(())
10272- }
10273-
10274- /// Set the unique post policy for a list.
10275- pub fn set_list_post_policy(&self, policy: PostPolicy) -> Result<DbVal<PostPolicy>> {
10276- if !(policy.announce_only
10277- || policy.subscription_only
10278- || policy.approval_needed
10279- || policy.open
10280- || policy.custom)
10281- {
10282- return Err(Error::new_external(
10283- "Cannot add empty policy. Having no policies is probably what you want to do.",
10284- ));
10285- }
10286- let list_pk = policy.list;
10287-
10288- let mut stmt = self.connection.prepare(
10289- "INSERT OR REPLACE INTO post_policy(list, announce_only, subscription_only, \
10290- approval_needed, open, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;",
10291- )?;
10292- let ret = stmt
10293- .query_row(
10294- rusqlite::params![
10295- &list_pk,
10296- &policy.announce_only,
10297- &policy.subscription_only,
10298- &policy.approval_needed,
10299- &policy.open,
10300- &policy.custom,
10301- ],
10302- |row| {
10303- let pk = row.get("pk")?;
10304- Ok(DbVal(
10305- PostPolicy {
10306- pk,
10307- list: row.get("list")?,
10308- announce_only: row.get("announce_only")?,
10309- subscription_only: row.get("subscription_only")?,
10310- approval_needed: row.get("approval_needed")?,
10311- open: row.get("open")?,
10312- custom: row.get("custom")?,
10313- },
10314- pk,
10315- ))
10316- },
10317- )
10318- .map_err(|err| {
10319- if matches!(
10320- err,
10321- rusqlite::Error::SqliteFailure(
10322- rusqlite::ffi::Error {
10323- code: rusqlite::ffi::ErrorCode::ConstraintViolation,
10324- extended_code: 787
10325- },
10326- _
10327- )
10328- ) {
10329- Error::from(err)
10330- .chain_err(|| NotFound("Could not find a list with this pk."))
10331- } else {
10332- err.into()
10333- }
10334- })?;
10335-
10336- trace!("set_list_post_policy {:?}.", &ret);
10337- Ok(ret)
10338- }
10339- }
10340- }
10341-
10342- mod subscription_policy {
10343- use log::trace;
10344- use rusqlite::OptionalExtension;
10345-
10346- use crate::{
10347- errors::{ErrorKind::*, *},
10348- models::{DbVal, SubscriptionPolicy},
10349- Connection,
10350- };
10351-
10352- impl Connection {
10353- /// Fetch the subscription policy of a mailing list.
10354- pub fn list_subscription_policy(
10355- &self,
10356- pk: i64,
10357- ) -> Result<Option<DbVal<SubscriptionPolicy>>> {
10358- let mut stmt = self
10359- .connection
10360- .prepare("SELECT * FROM subscription_policy WHERE list = ?;")?;
10361- let ret = stmt
10362- .query_row([&pk], |row| {
10363- let pk = row.get("pk")?;
10364- Ok(DbVal(
10365- SubscriptionPolicy {
10366- pk,
10367- list: row.get("list")?,
10368- send_confirmation: row.get("send_confirmation")?,
10369- open: row.get("open")?,
10370- manual: row.get("manual")?,
10371- request: row.get("request")?,
10372- custom: row.get("custom")?,
10373- },
10374- pk,
10375- ))
10376- })
10377- .optional()?;
10378-
10379- Ok(ret)
10380- }
10381-
10382- /// Remove an existing subscription policy.
10383- ///
10384- /// # Examples
10385- ///
10386- /// ```
10387- /// # use mailpot::{models::*, Configuration, Connection, SendMail};
10388- /// # use tempfile::TempDir;
10389- /// #
10390- /// # let tmp_dir = TempDir::new().unwrap();
10391- /// # let db_path = tmp_dir.path().join("mpot.db");
10392- /// # let config = Configuration {
10393- /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
10394- /// # db_path: db_path.clone(),
10395- /// # data_path: tmp_dir.path().to_path_buf(),
10396- /// # administrators: vec![],
10397- /// # };
10398- /// #
10399- /// # fn do_test(config: Configuration) {
10400- /// let db = Connection::open_or_create_db(config).unwrap().trusted();
10401- /// let list = db
10402- /// .create_list(MailingList {
10403- /// pk: 0,
10404- /// name: "foobar chat".into(),
10405- /// id: "foo-chat".into(),
10406- /// address: "foo-chat@example.com".into(),
10407- /// description: None,
10408- /// topics: vec![],
10409- /// archive_url: None,
10410- /// })
10411- /// .unwrap();
10412- /// # assert!(db.list_subscription_policy(list.pk()).unwrap().is_none());
10413- /// let pol = db
10414- /// .set_list_subscription_policy(SubscriptionPolicy {
10415- /// pk: -1,
10416- /// list: list.pk(),
10417- /// send_confirmation: false,
10418- /// open: true,
10419- /// manual: false,
10420- /// request: false,
10421- /// custom: false,
10422- /// })
10423- /// .unwrap();
10424- /// # assert_eq!(db.list_subscription_policy(list.pk()).unwrap().as_ref(), Some(&pol));
10425- /// db.remove_list_subscription_policy(list.pk(), pol.pk())
10426- /// .unwrap();
10427- /// # assert!(db.list_subscription_policy(list.pk()).unwrap().is_none());
10428- /// # }
10429- /// # do_test(config);
10430- /// ```
10431- ///
10432- /// ```should_panic
10433- /// # use mailpot::{models::*, Configuration, Connection, SendMail};
10434- /// # use tempfile::TempDir;
10435- /// #
10436- /// # let tmp_dir = TempDir::new().unwrap();
10437- /// # let db_path = tmp_dir.path().join("mpot.db");
10438- /// # let config = Configuration {
10439- /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
10440- /// # db_path: db_path.clone(),
10441- /// # data_path: tmp_dir.path().to_path_buf(),
10442- /// # administrators: vec![],
10443- /// # };
10444- /// #
10445- /// # fn do_test(config: Configuration) {
10446- /// let db = Connection::open_or_create_db(config).unwrap().trusted();
10447- /// db.remove_list_post_policy(1, 1).unwrap();
10448- /// # }
10449- /// # do_test(config);
10450- /// ```
10451- pub fn remove_list_subscription_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
10452- let mut stmt = self.connection.prepare(
10453- "DELETE FROM subscription_policy WHERE pk = ? AND list = ? RETURNING *;",
10454- )?;
10455- stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
10456- .map_err(|err| {
10457- if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
10458- Error::from(err).chain_err(|| NotFound("list or list policy not found!"))
10459- } else {
10460- err.into()
10461- }
10462- })?;
10463-
10464- trace!("remove_list_subscription_policy {} {}.", list_pk, policy_pk);
10465- Ok(())
10466- }
10467-
10468- /// Set the unique post policy for a list.
10469- pub fn set_list_subscription_policy(
10470- &self,
10471- policy: SubscriptionPolicy,
10472- ) -> Result<DbVal<SubscriptionPolicy>> {
10473- if !(policy.open || policy.manual || policy.request || policy.custom) {
10474- return Err(Error::new_external(
10475- "Cannot add empty policy. Having no policy is probably what you want to do.",
10476- ));
10477- }
10478- let list_pk = policy.list;
10479-
10480- let mut stmt = self.connection.prepare(
10481- "INSERT OR REPLACE INTO subscription_policy(list, send_confirmation, open, \
10482- manual, request, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;",
10483- )?;
10484- let ret = stmt
10485- .query_row(
10486- rusqlite::params![
10487- &list_pk,
10488- &policy.send_confirmation,
10489- &policy.open,
10490- &policy.manual,
10491- &policy.request,
10492- &policy.custom,
10493- ],
10494- |row| {
10495- let pk = row.get("pk")?;
10496- Ok(DbVal(
10497- SubscriptionPolicy {
10498- pk,
10499- list: row.get("list")?,
10500- send_confirmation: row.get("send_confirmation")?,
10501- open: row.get("open")?,
10502- manual: row.get("manual")?,
10503- request: row.get("request")?,
10504- custom: row.get("custom")?,
10505- },
10506- pk,
10507- ))
10508- },
10509- )
10510- .map_err(|err| {
10511- if matches!(
10512- err,
10513- rusqlite::Error::SqliteFailure(
10514- rusqlite::ffi::Error {
10515- code: rusqlite::ffi::ErrorCode::ConstraintViolation,
10516- extended_code: 787
10517- },
10518- _
10519- )
10520- ) {
10521- Error::from(err)
10522- .chain_err(|| NotFound("Could not find a list with this pk."))
10523- } else {
10524- err.into()
10525- }
10526- })?;
10527-
10528- trace!("set_list_subscription_policy {:?}.", &ret);
10529- Ok(ret)
10530- }
10531- }
10532- }
10533 diff --git a/core/src/postfix.rs b/core/src/postfix.rs
10534deleted file mode 100644
10535index 519f803..0000000
10536--- a/core/src/postfix.rs
10537+++ /dev/null
10538 @@ -1,678 +0,0 @@
10539- /*
10540- * This file is part of mailpot
10541- *
10542- * Copyright 2020 - Manos Pitsidianakis
10543- *
10544- * This program is free software: you can redistribute it and/or modify
10545- * it under the terms of the GNU Affero General Public License as
10546- * published by the Free Software Foundation, either version 3 of the
10547- * License, or (at your option) any later version.
10548- *
10549- * This program is distributed in the hope that it will be useful,
10550- * but WITHOUT ANY WARRANTY; without even the implied warranty of
10551- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10552- * GNU Affero General Public License for more details.
10553- *
10554- * You should have received a copy of the GNU Affero General Public License
10555- * along with this program. If not, see <https://www.gnu.org/licenses/>.
10556- */
10557-
10558- //! Generate configuration for the postfix mail server.
10559- //!
10560- //! ## Transport maps (`transport_maps`)
10561- //!
10562- //! <http://www.postfix.org/postconf.5.html#transport_maps>
10563- //!
10564- //! ## Local recipient maps (`local_recipient_maps`)
10565- //!
10566- //! <http://www.postfix.org/postconf.5.html#local_recipient_maps>
10567- //!
10568- //! ## Relay domains (`relay_domains`)
10569- //!
10570- //! <http://www.postfix.org/postconf.5.html#relay_domains>
10571-
10572- use std::{
10573- borrow::Cow,
10574- convert::TryInto,
10575- fs::OpenOptions,
10576- io::{BufWriter, Read, Seek, Write},
10577- path::{Path, PathBuf},
10578- };
10579-
10580- use crate::{errors::*, Configuration, Connection, DbVal, MailingList, PostPolicy};
10581-
10582- /*
10583- transport_maps =
10584- hash:/path-to-mailman/var/data/postfix_lmtp
10585- local_recipient_maps =
10586- hash:/path-to-mailman/var/data/postfix_lmtp
10587- relay_domains =
10588- hash:/path-to-mailman/var/data/postfix_domains
10589- */
10590-
10591- /// Settings for generating postfix configuration.
10592- ///
10593- /// See the struct methods for details.
10594- #[derive(Debug, Clone, Deserialize, Serialize)]
10595- pub struct PostfixConfiguration {
10596- /// The UNIX username under which the mailpot process who processed incoming
10597- /// mail is launched.
10598- pub user: Cow<'static, str>,
10599- /// The UNIX group under which the mailpot process who processed incoming
10600- /// mail is launched.
10601- pub group: Option<Cow<'static, str>>,
10602- /// The absolute path of the `mailpot` binary.
10603- pub binary_path: PathBuf,
10604- /// The maximum number of `mailpot` processes to launch. Default is `1`.
10605- #[serde(default)]
10606- pub process_limit: Option<u64>,
10607- /// The directory in which the map files are saved.
10608- /// Default is `data_path` from [`Configuration`].
10609- #[serde(default)]
10610- pub map_output_path: Option<PathBuf>,
10611- /// The name of the Postfix service name to use.
10612- /// Default is `mailpot`.
10613- ///
10614- /// A Postfix service is a daemon managed by the postfix process.
10615- /// Each entry in the `master.cf` configuration file defines a single
10616- /// service.
10617- ///
10618- /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
10619- /// <https://www.postfix.org/master.5.html>.
10620- #[serde(default)]
10621- pub transport_name: Option<Cow<'static, str>>,
10622- }
10623-
10624- impl Default for PostfixConfiguration {
10625- fn default() -> Self {
10626- Self {
10627- user: "user".into(),
10628- group: None,
10629- binary_path: Path::new("/usr/bin/mailpot").to_path_buf(),
10630- process_limit: None,
10631- map_output_path: None,
10632- transport_name: None,
10633- }
10634- }
10635- }
10636-
10637- impl PostfixConfiguration {
10638- /// Generate service line entry for Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
10639- pub fn generate_master_cf_entry(&self, config: &Configuration, config_path: &Path) -> String {
10640- let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
10641- format!(
10642- "{transport_name} unix - n n - {process_limit} pipe
10643- flags=RX user={username}{group_sep}{groupname} directory={{{data_dir}}} argv={{{binary_path}}} -c \
10644- {{{config_path}}} post",
10645- username = &self.user,
10646- group_sep = if self.group.is_none() { "" } else { ":" },
10647- groupname = self.group.as_deref().unwrap_or_default(),
10648- process_limit = self.process_limit.unwrap_or(1),
10649- binary_path = &self.binary_path.display(),
10650- config_path = &config_path.display(),
10651- data_dir = &config.data_path.display()
10652- )
10653- }
10654-
10655- /// Generate `transport_maps` and `local_recipient_maps` for Postfix.
10656- ///
10657- /// The output must be saved in a plain text file.
10658- /// To make Postfix be able to read them, the `postmap` application must be
10659- /// executed with the path to the map file as its sole argument.
10660- /// `postmap` is usually distributed along with the other Postfix binaries.
10661- pub fn generate_maps(
10662- &self,
10663- lists: &[(DbVal<MailingList>, Option<DbVal<PostPolicy>>)],
10664- ) -> String {
10665- let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
10666- let mut ret = String::new();
10667- ret.push_str("# Automatically generated by mailpot.\n");
10668- ret.push_str(
10669- "# Upon its creation and every time it is modified, postmap(1) must be called for the \
10670- changes to take effect:\n",
10671- );
10672- ret.push_str("# postmap /path/to/map_file\n\n");
10673-
10674- // [ref:TODO]: add custom addresses if PostPolicy is custom
10675- let calc_width = |list: &MailingList, policy: Option<&PostPolicy>| -> usize {
10676- let addr = list.address.len();
10677- match policy {
10678- None => 0,
10679- Some(PostPolicy { .. }) => addr + "+request".len(),
10680- }
10681- };
10682-
10683- let Some(width): Option<usize> =
10684- lists.iter().map(|(l, p)| calc_width(l, p.as_deref())).max()
10685- else {
10686- return ret;
10687- };
10688-
10689- for (list, policy) in lists {
10690- macro_rules! push_addr {
10691- ($addr:expr) => {{
10692- let addr = &$addr;
10693- ret.push_str(addr);
10694- for _ in 0..(width - addr.len() + 5) {
10695- ret.push(' ');
10696- }
10697- ret.push_str(transport_name);
10698- ret.push_str(":\n");
10699- }};
10700- }
10701-
10702- match policy.as_deref() {
10703- None => log::debug!(
10704- "Not generating postfix map entry for list {} because it has no post_policy \
10705- set.",
10706- list.id
10707- ),
10708- Some(PostPolicy { open: true, .. }) => {
10709- push_addr!(list.address);
10710- ret.push('\n');
10711- }
10712- Some(PostPolicy { .. }) => {
10713- push_addr!(list.address);
10714- push_addr!(list.subscription_mailto().address);
10715- push_addr!(list.owner_mailto().address);
10716- ret.push('\n');
10717- }
10718- }
10719- }
10720-
10721- // pop second of the last two newlines
10722- ret.pop();
10723-
10724- ret
10725- }
10726-
10727- /// Save service to Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
10728- ///
10729- /// If you wish to do it manually, get the text output from
10730- /// [`PostfixConfiguration::generate_master_cf_entry`] and manually append it to the [`master.cf`](https://www.postfix.org/master.5.html) file.
10731- ///
10732- /// If `master_cf_path` is `None`, the location of the file is assumed to be
10733- /// `/etc/postfix/master.cf`.
10734- pub fn save_master_cf_entry(
10735- &self,
10736- config: &Configuration,
10737- config_path: &Path,
10738- master_cf_path: Option<&Path>,
10739- ) -> Result<()> {
10740- let new_entry = self.generate_master_cf_entry(config, config_path);
10741- let path = master_cf_path.unwrap_or_else(|| Path::new("/etc/postfix/master.cf"));
10742-
10743- // Create backup file.
10744- let path_bkp = path.with_extension("cf.bkp");
10745- std::fs::copy(path, &path_bkp).context(format!(
10746- "Could not create master.cf backup {}",
10747- path_bkp.display()
10748- ))?;
10749- log::info!(
10750- "Created backup of {} to {}.",
10751- path.display(),
10752- path_bkp.display()
10753- );
10754-
10755- let mut file = OpenOptions::new()
10756- .read(true)
10757- .write(true)
10758- .create(false)
10759- .open(path)
10760- .context(format!("Could not open {}", path.display()))?;
10761-
10762- let mut previous_content = String::new();
10763-
10764- file.rewind()
10765- .context(format!("Could not access {}", path.display()))?;
10766- file.read_to_string(&mut previous_content)
10767- .context(format!("Could not access {}", path.display()))?;
10768-
10769- let original_size = previous_content.len();
10770-
10771- let lines = previous_content.lines().collect::<Vec<&str>>();
10772- let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
10773-
10774- if let Some(line) = lines.iter().find(|l| l.starts_with(transport_name)) {
10775- let pos = previous_content.find(line).ok_or_else(|| {
10776- Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
10777- })?;
10778- let end_needle = " argv=";
10779- let end_pos = previous_content[pos..]
10780- .find(end_needle)
10781- .and_then(|pos2| {
10782- previous_content[(pos + pos2 + end_needle.len())..]
10783- .find('\n')
10784- .map(|p| p + pos + pos2 + end_needle.len())
10785- })
10786- .ok_or_else(|| {
10787- Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
10788- })?;
10789- previous_content.replace_range(pos..end_pos, &new_entry);
10790- } else {
10791- previous_content.push_str(&new_entry);
10792- previous_content.push('\n');
10793- }
10794-
10795- file.rewind()?;
10796- if previous_content.len() < original_size {
10797- file.set_len(
10798- previous_content
10799- .len()
10800- .try_into()
10801- .expect("Could not convert usize file size to u64"),
10802- )?;
10803- }
10804- let mut file = BufWriter::new(file);
10805- file.write_all(previous_content.as_bytes())
10806- .context(format!("Could not access {}", path.display()))?;
10807- file.flush()
10808- .context(format!("Could not access {}", path.display()))?;
10809- log::debug!("Saved new master.cf to {}.", path.display(),);
10810-
10811- Ok(())
10812- }
10813-
10814- /// Generate `transport_maps` and `local_recipient_maps` for Postfix.
10815- ///
10816- /// To succeed the user the command is running under must have write and
10817- /// read access to `postfix_data_directory` and the `postmap` binary
10818- /// must be discoverable in your `PATH` environment variable.
10819- ///
10820- /// `postmap` is usually distributed along with the other Postfix binaries.
10821- pub fn save_maps(&self, config: &Configuration) -> Result<()> {
10822- let db = Connection::open_db(config.clone())?;
10823- let Some(postmap) = find_binary_in_path("postmap") else {
10824- return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
10825- "Could not find postmap binary in PATH.",
10826- ))));
10827- };
10828- let lists = db.lists()?;
10829- let lists_post_policies = lists
10830- .into_iter()
10831- .map(|l| {
10832- let pk = l.pk;
10833- Ok((l, db.list_post_policy(pk)?))
10834- })
10835- .collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
10836- let content = self.generate_maps(&lists_post_policies);
10837- let path = self
10838- .map_output_path
10839- .as_deref()
10840- .unwrap_or(&config.data_path)
10841- .join("mailpot_postfix_map");
10842- let mut file = BufWriter::new(
10843- OpenOptions::new()
10844- .read(true)
10845- .write(true)
10846- .create(true)
10847- .truncate(true)
10848- .open(&path)
10849- .context(format!("Could not open {}", path.display()))?,
10850- );
10851- file.write_all(content.as_bytes())
10852- .context(format!("Could not write to {}", path.display()))?;
10853- file.flush()
10854- .context(format!("Could not write to {}", path.display()))?;
10855-
10856- let output = std::process::Command::new("sh")
10857- .arg("-c")
10858- .arg(&format!("{} {}", postmap.display(), path.display()))
10859- .output()
10860- .with_context(|| {
10861- format!(
10862- "Could not execute `postmap` binary in path {}",
10863- postmap.display()
10864- )
10865- })?;
10866- if !output.status.success() {
10867- use std::os::unix::process::ExitStatusExt;
10868- if let Some(code) = output.status.code() {
10869- return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
10870- format!(
10871- "{} exited with {}.\nstderr was:\n---{}---\nstdout was\n---{}---\n",
10872- code,
10873- postmap.display(),
10874- String::from_utf8_lossy(&output.stderr),
10875- String::from_utf8_lossy(&output.stdout)
10876- ),
10877- ))));
10878- } else if let Some(signum) = output.status.signal() {
10879- return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
10880- format!(
10881- "{} was killed with signal {}.\nstderr was:\n---{}---\nstdout \
10882- was\n---{}---\n",
10883- signum,
10884- postmap.display(),
10885- String::from_utf8_lossy(&output.stderr),
10886- String::from_utf8_lossy(&output.stdout)
10887- ),
10888- ))));
10889- } else {
10890- return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
10891- format!(
10892- "{} failed for unknown reason.\nstderr was:\n---{}---\nstdout \
10893- was\n---{}---\n",
10894- postmap.display(),
10895- String::from_utf8_lossy(&output.stderr),
10896- String::from_utf8_lossy(&output.stdout)
10897- ),
10898- ))));
10899- }
10900- }
10901-
10902- Ok(())
10903- }
10904- }
10905-
10906- fn find_binary_in_path(binary_name: &str) -> Option<PathBuf> {
10907- std::env::var_os("PATH").and_then(|paths| {
10908- std::env::split_paths(&paths).find_map(|dir| {
10909- let full_path = dir.join(binary_name);
10910- if full_path.is_file() {
10911- Some(full_path)
10912- } else {
10913- None
10914- }
10915- })
10916- })
10917- }
10918-
10919- #[test]
10920- fn test_postfix_generation() -> Result<()> {
10921- use tempfile::TempDir;
10922-
10923- use crate::*;
10924-
10925- mailpot_tests::init_stderr_logging();
10926-
10927- fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
10928- use melib::smtp::*;
10929- SmtpServerConf {
10930- hostname: "127.0.0.1".into(),
10931- port: 1025,
10932- envelope_from: "foo-chat@example.com".into(),
10933- auth: SmtpAuth::None,
10934- security: SmtpSecurity::None,
10935- extensions: Default::default(),
10936- }
10937- }
10938-
10939- let tmp_dir = TempDir::new()?;
10940-
10941- let db_path = tmp_dir.path().join("mpot.db");
10942- let config = Configuration {
10943- send_mail: SendMail::Smtp(get_smtp_conf()),
10944- db_path,
10945- data_path: tmp_dir.path().to_path_buf(),
10946- administrators: vec![],
10947- };
10948- let config_path = tmp_dir.path().join("conf.toml");
10949- {
10950- let mut conf = OpenOptions::new()
10951- .write(true)
10952- .create(true)
10953- .open(&config_path)?;
10954- conf.write_all(config.to_toml().as_bytes())?;
10955- conf.flush()?;
10956- }
10957-
10958- let db = Connection::open_or_create_db(config)?.trusted();
10959- assert!(db.lists()?.is_empty());
10960-
10961- // Create three lists:
10962- //
10963- // - One without any policy, which should not show up in postfix maps.
10964- // - One with subscriptions disabled, which would only add the list address in
10965- // postfix maps.
10966- // - One with subscriptions enabled, which should add all addresses (list,
10967- // list+{un,}subscribe, etc).
10968-
10969- let first = db.create_list(MailingList {
10970- pk: 0,
10971- name: "first".into(),
10972- id: "first".into(),
10973- address: "first@example.com".into(),
10974- description: None,
10975- topics: vec![],
10976- archive_url: None,
10977- })?;
10978- assert_eq!(first.pk(), 1);
10979- let second = db.create_list(MailingList {
10980- pk: 0,
10981- name: "second".into(),
10982- id: "second".into(),
10983- address: "second@example.com".into(),
10984- description: None,
10985- topics: vec![],
10986- archive_url: None,
10987- })?;
10988- assert_eq!(second.pk(), 2);
10989- let post_policy = db.set_list_post_policy(PostPolicy {
10990- pk: 0,
10991- list: second.pk(),
10992- announce_only: false,
10993- subscription_only: false,
10994- approval_needed: false,
10995- open: true,
10996- custom: false,
10997- })?;
10998-
10999- assert_eq!(post_policy.pk(), 1);
11000- let third = db.create_list(MailingList {
11001- pk: 0,
11002- name: "third".into(),
11003- id: "third".into(),
11004- address: "third@example.com".into(),
11005- description: None,
11006- topics: vec![],
11007- archive_url: None,
11008- })?;
11009- assert_eq!(third.pk(), 3);
11010- let post_policy = db.set_list_post_policy(PostPolicy {
11011- pk: 0,
11012- list: third.pk(),
11013- announce_only: false,
11014- subscription_only: false,
11015- approval_needed: true,
11016- open: false,
11017- custom: false,
11018- })?;
11019-
11020- assert_eq!(post_policy.pk(), 2);
11021-
11022- let mut postfix_conf = PostfixConfiguration::default();
11023-
11024- let expected_mastercf_entry = format!(
11025- "mailpot unix - n n - 1 pipe
11026- flags=RX user={} directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
11027- &postfix_conf.user,
11028- tmp_dir.path().display(),
11029- config_path.display()
11030- );
11031- assert_eq!(
11032- expected_mastercf_entry.trim_end(),
11033- postfix_conf.generate_master_cf_entry(db.conf(), &config_path)
11034- );
11035-
11036- let lists = db.lists()?;
11037- let lists_post_policies = lists
11038- .into_iter()
11039- .map(|l| {
11040- let pk = l.pk;
11041- Ok((l, db.list_post_policy(pk)?))
11042- })
11043- .collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
11044- let maps = postfix_conf.generate_maps(&lists_post_policies);
11045-
11046- let expected = "second@example.com mailpot:
11047-
11048- third@example.com mailpot:
11049- third+request@example.com mailpot:
11050- third+owner@example.com mailpot:
11051- ";
11052- assert!(
11053- maps.ends_with(expected),
11054- "maps has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
11055- maps,
11056- expected
11057- );
11058-
11059- let master_edit_value = r#"#
11060- # Postfix master process configuration file. For details on the format
11061- # of the file, see the master(5) manual page (command: "man 5 master" or
11062- # on-line: http://www.postfix.org/master.5.html).
11063- #
11064- # Do not forget to execute "postfix reload" after editing this file.
11065- #
11066- # ==========================================================================
11067- # service type private unpriv chroot wakeup maxproc command + args
11068- # (yes) (yes) (no) (never) (100)
11069- # ==========================================================================
11070- smtp inet n - y - - smtpd
11071- pickup unix n - y 60 1 pickup
11072- cleanup unix n - y - 0 cleanup
11073- qmgr unix n - n 300 1 qmgr
11074- #qmgr unix n - n 300 1 oqmgr
11075- tlsmgr unix - - y 1000? 1 tlsmgr
11076- rewrite unix - - y - - trivial-rewrite
11077- bounce unix - - y - 0 bounce
11078- defer unix - - y - 0 bounce
11079- trace unix - - y - 0 bounce
11080- verify unix - - y - 1 verify
11081- flush unix n - y 1000? 0 flush
11082- proxymap unix - - n - - proxymap
11083- proxywrite unix - - n - 1 proxymap
11084- smtp unix - - y - - smtp
11085- relay unix - - y - - smtp
11086- -o syslog_name=postfix/$service_name
11087- showq unix n - y - - showq
11088- error unix - - y - - error
11089- retry unix - - y - - error
11090- discard unix - - y - - discard
11091- local unix - n n - - local
11092- virtual unix - n n - - virtual
11093- lmtp unix - - y - - lmtp
11094- anvil unix - - y - 1 anvil
11095- scache unix - - y - 1 scache
11096- postlog unix-dgram n - n - 1 postlogd
11097- maildrop unix - n n - - pipe
11098- flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
11099- uucp unix - n n - - pipe
11100- flags=Fqhu user=uucp argv=uux -r -n -z -a$sender - $nexthop!rmail ($recipient)
11101- #
11102- # Other external delivery methods.
11103- #
11104- ifmail unix - n n - - pipe
11105- flags=F user=ftn argv=/usr/lib/ifmail/ifmail -r $nexthop ($recipient)
11106- bsmtp unix - n n - - pipe
11107- flags=Fq. user=bsmtp argv=/usr/lib/bsmtp/bsmtp -t$nexthop -f$sender $recipient
11108- scalemail-backend unix - n n - 2 pipe
11109- flags=R user=scalemail argv=/usr/lib/scalemail/bin/scalemail-store ${nexthop} ${user} ${extension}
11110- mailman unix - n n - - pipe
11111- flags=FRX user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py ${nexthop} ${user}
11112- "#;
11113-
11114- let path = tmp_dir.path().join("master.cf");
11115- {
11116- let mut mastercf = OpenOptions::new().write(true).create(true).open(&path)?;
11117- mastercf.write_all(master_edit_value.as_bytes())?;
11118- mastercf.flush()?;
11119- }
11120- postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
11121- let mut first = String::new();
11122- {
11123- let mut mastercf = OpenOptions::new()
11124- .write(false)
11125- .read(true)
11126- .create(false)
11127- .open(&path)?;
11128- mastercf.read_to_string(&mut first)?;
11129- }
11130- assert!(
11131- first.ends_with(&expected_mastercf_entry),
11132- "edited master.cf has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
11133- first,
11134- expected_mastercf_entry
11135- );
11136-
11137- // test that a smaller entry can be successfully replaced
11138-
11139- postfix_conf.user = "nobody".into();
11140- postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
11141- let mut second = String::new();
11142- {
11143- let mut mastercf = OpenOptions::new()
11144- .write(false)
11145- .read(true)
11146- .create(false)
11147- .open(&path)?;
11148- mastercf.read_to_string(&mut second)?;
11149- }
11150- let expected_mastercf_entry = format!(
11151- "mailpot unix - n n - 1 pipe
11152- flags=RX user=nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
11153- tmp_dir.path().display(),
11154- config_path.display()
11155- );
11156- assert!(
11157- second.ends_with(&expected_mastercf_entry),
11158- "doubly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
11159- with\n{:?}",
11160- second,
11161- expected_mastercf_entry
11162- );
11163- // test that a larger entry can be successfully replaced
11164- postfix_conf.user = "hackerman".into();
11165- postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
11166- let mut third = String::new();
11167- {
11168- let mut mastercf = OpenOptions::new()
11169- .write(false)
11170- .read(true)
11171- .create(false)
11172- .open(&path)?;
11173- mastercf.read_to_string(&mut third)?;
11174- }
11175- let expected_mastercf_entry = format!(
11176- "mailpot unix - n n - 1 pipe
11177- flags=RX user=hackerman directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
11178- tmp_dir.path().display(),
11179- config_path.display(),
11180- );
11181- assert!(
11182- third.ends_with(&expected_mastercf_entry),
11183- "triply edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
11184- with\n{:?}",
11185- third,
11186- expected_mastercf_entry
11187- );
11188-
11189- // test that if groupname is given it is rendered correctly.
11190- postfix_conf.group = Some("nobody".into());
11191- postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
11192- let mut fourth = String::new();
11193- {
11194- let mut mastercf = OpenOptions::new()
11195- .write(false)
11196- .read(true)
11197- .create(false)
11198- .open(&path)?;
11199- mastercf.read_to_string(&mut fourth)?;
11200- }
11201- let expected_mastercf_entry = format!(
11202- "mailpot unix - n n - 1 pipe
11203- flags=RX user=hackerman:nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
11204- tmp_dir.path().display(),
11205- config_path.display(),
11206- );
11207- assert!(
11208- fourth.ends_with(&expected_mastercf_entry),
11209- "fourthly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
11210- with\n{:?}",
11211- fourth,
11212- expected_mastercf_entry
11213- );
11214-
11215- Ok(())
11216- }
11217 diff --git a/core/src/posts.rs b/core/src/posts.rs
11218deleted file mode 100644
11219index d3525dd..0000000
11220--- a/core/src/posts.rs
11221+++ /dev/null
11222 @@ -1,801 +0,0 @@
11223- /*
11224- * This file is part of mailpot
11225- *
11226- * Copyright 2020 - Manos Pitsidianakis
11227- *
11228- * This program is free software: you can redistribute it and/or modify
11229- * it under the terms of the GNU Affero General Public License as
11230- * published by the Free Software Foundation, either version 3 of the
11231- * License, or (at your option) any later version.
11232- *
11233- * This program is distributed in the hope that it will be useful,
11234- * but WITHOUT ANY WARRANTY; without even the implied warranty of
11235- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11236- * GNU Affero General Public License for more details.
11237- *
11238- * You should have received a copy of the GNU Affero General Public License
11239- * along with this program. If not, see <https://www.gnu.org/licenses/>.
11240- */
11241-
11242- //! Processing new posts.
11243-
11244- use std::borrow::Cow;
11245-
11246- use log::{info, trace};
11247- use melib::Envelope;
11248- use rusqlite::OptionalExtension;
11249-
11250- use crate::{
11251- errors::*,
11252- mail::{ListContext, ListRequest, PostAction, PostEntry},
11253- models::{changesets::AccountChangeset, Account, DbVal, ListSubscription, MailingList, Post},
11254- queue::{Queue, QueueEntry},
11255- templates::Template,
11256- Connection,
11257- };
11258-
11259- impl Connection {
11260- /// Insert a mailing list post into the database.
11261- pub fn insert_post(&self, list_pk: i64, message: &[u8], env: &Envelope) -> Result<i64> {
11262- let from_ = env.from();
11263- let address = if from_.is_empty() {
11264- String::new()
11265- } else {
11266- from_[0].get_email()
11267- };
11268- let datetime: std::borrow::Cow<'_, str> = if !env.date.is_empty() {
11269- env.date.as_str().into()
11270- } else {
11271- melib::utils::datetime::timestamp_to_string(
11272- env.timestamp,
11273- Some(melib::utils::datetime::formats::RFC822_DATE),
11274- true,
11275- )
11276- .into()
11277- };
11278- let message_id = env.message_id_display();
11279- let mut stmt = self.connection.prepare(
11280- "INSERT OR REPLACE INTO post(list, address, message_id, message, datetime, timestamp) \
11281- VALUES(?, ?, ?, ?, ?, ?) RETURNING pk;",
11282- )?;
11283- let pk = stmt.query_row(
11284- rusqlite::params![
11285- &list_pk,
11286- &address,
11287- &message_id,
11288- &message,
11289- &datetime,
11290- &env.timestamp
11291- ],
11292- |row| {
11293- let pk: i64 = row.get("pk")?;
11294- Ok(pk)
11295- },
11296- )?;
11297-
11298- trace!(
11299- "insert_post list_pk {}, from {:?} message_id {:?} post_pk {}.",
11300- list_pk,
11301- address,
11302- message_id,
11303- pk
11304- );
11305- Ok(pk)
11306- }
11307-
11308- /// Process a new mailing list post.
11309- ///
11310- /// In case multiple processes can access the database at any time, use an
11311- /// `EXCLUSIVE` transaction before calling this function.
11312- /// See [`Connection::transaction`].
11313- pub fn post(&self, env: &Envelope, raw: &[u8], _dry_run: bool) -> Result<()> {
11314- let result = self.inner_post(env, raw, _dry_run);
11315- if let Err(err) = result {
11316- return match self.insert_to_queue(QueueEntry::new(
11317- Queue::Error,
11318- None,
11319- Some(Cow::Borrowed(env)),
11320- raw,
11321- Some(err.to_string()),
11322- )?) {
11323- Ok(idx) => {
11324- log::info!(
11325- "Inserted mail from {:?} into error_queue at index {}",
11326- env.from(),
11327- idx
11328- );
11329- Err(err)
11330- }
11331- Err(err2) => {
11332- log::error!(
11333- "Could not insert mail from {:?} into error_queue: {err2}",
11334- env.from(),
11335- );
11336-
11337- Err(err.chain_err(|| err2))
11338- }
11339- };
11340- }
11341- result
11342- }
11343-
11344- fn inner_post(&self, env: &Envelope, raw: &[u8], _dry_run: bool) -> Result<()> {
11345- trace!("Received envelope to post: {:#?}", &env);
11346- let tos = env.to().to_vec();
11347- if tos.is_empty() {
11348- return Err("Envelope To: field is empty!".into());
11349- }
11350- if env.from().is_empty() {
11351- return Err("Envelope From: field is empty!".into());
11352- }
11353- let mut lists = self.lists()?;
11354- let prev_list_len = lists.len();
11355- for t in &tos {
11356- if let Some((addr, subaddr)) = t.subaddress("+") {
11357- lists.retain(|list| {
11358- if !addr.contains_address(&list.address()) {
11359- return true;
11360- }
11361- if let Err(err) = ListRequest::try_from((subaddr.as_str(), env))
11362- .and_then(|req| self.request(list, req, env, raw))
11363- {
11364- info!("Processing request returned error: {}", err);
11365- }
11366- false
11367- });
11368- if lists.len() != prev_list_len {
11369- // Was request, handled above.
11370- return Ok(());
11371- }
11372- }
11373- }
11374-
11375- lists.retain(|list| {
11376- trace!(
11377- "Is post related to list {}? {}",
11378- &list,
11379- tos.iter().any(|a| a.contains_address(&list.address()))
11380- );
11381-
11382- tos.iter().any(|a| a.contains_address(&list.address()))
11383- });
11384- if lists.is_empty() {
11385- return Err(format!(
11386- "No relevant mailing list found for these addresses: {:?}",
11387- tos
11388- )
11389- .into());
11390- }
11391-
11392- trace!("Configuration is {:#?}", &self.conf);
11393- for mut list in lists {
11394- trace!("Examining list {}", list.display_name());
11395- let filters = self.list_filters(&list);
11396- let subscriptions = self.list_subscriptions(list.pk)?;
11397- let owners = self.list_owners(list.pk)?;
11398- trace!("List subscriptions {:#?}", &subscriptions);
11399- let mut list_ctx = ListContext {
11400- post_policy: self.list_post_policy(list.pk)?,
11401- subscription_policy: self.list_subscription_policy(list.pk)?,
11402- list_owners: &owners,
11403- subscriptions: &subscriptions,
11404- scheduled_jobs: vec![],
11405- filter_settings: self.get_settings(list.pk)?,
11406- list: &mut list,
11407- };
11408- let mut post = PostEntry {
11409- message_id: env.message_id().clone(),
11410- from: env.from()[0].clone(),
11411- bytes: raw.to_vec(),
11412- to: env.to().to_vec(),
11413- action: PostAction::Hold,
11414- };
11415- let result = filters
11416- .into_iter()
11417- .try_fold((&mut post, &mut list_ctx), |(p, c), f| f.feed(p, c));
11418- trace!("result {:#?}", result);
11419-
11420- let PostEntry { bytes, action, .. } = post;
11421- trace!("Action is {:#?}", action);
11422- let post_env = melib::Envelope::from_bytes(&bytes, None)?;
11423- match action {
11424- PostAction::Accept => {
11425- let _post_pk = self.insert_post(list_ctx.list.pk, &bytes, &post_env)?;
11426- trace!("post_pk is {:#?}", _post_pk);
11427- for job in list_ctx.scheduled_jobs.iter() {
11428- trace!("job is {:#?}", &job);
11429- if let crate::mail::MailJob::Send { recipients } = job {
11430- trace!("recipients: {:?}", &recipients);
11431- if recipients.is_empty() {
11432- trace!("list has no recipients");
11433- }
11434- for recipient in recipients {
11435- let mut env = post_env.clone();
11436- env.set_to(melib::smallvec::smallvec![recipient.clone()]);
11437- self.insert_to_queue(QueueEntry::new(
11438- Queue::Out,
11439- Some(list.pk),
11440- Some(Cow::Owned(env)),
11441- &bytes,
11442- None,
11443- )?)?;
11444- }
11445- }
11446- }
11447- }
11448- PostAction::Reject { reason } => {
11449- log::info!("PostAction::Reject {{ reason: {} }}", reason);
11450- for f in env.from() {
11451- /* send error notice to e-mail sender */
11452- self.send_reply_with_list_template(
11453- TemplateRenderContext {
11454- template: Template::GENERIC_FAILURE,
11455- default_fn: Some(Template::default_generic_failure),
11456- list: &list,
11457- context: minijinja::context! {
11458- list => &list,
11459- subject => format!("Your post to {} was rejected.", list.id),
11460- details => &reason,
11461- },
11462- queue: Queue::Out,
11463- comment: format!("PostAction::Reject {{ reason: {} }}", reason)
11464- .into(),
11465- },
11466- std::iter::once(Cow::Borrowed(f)),
11467- )?;
11468- }
11469- /* error handled by notifying submitter */
11470- return Ok(());
11471- }
11472- PostAction::Defer { reason } => {
11473- trace!("PostAction::Defer {{ reason: {} }}", reason);
11474- for f in env.from() {
11475- /* send error notice to e-mail sender */
11476- self.send_reply_with_list_template(
11477- TemplateRenderContext {
11478- template: Template::GENERIC_FAILURE,
11479- default_fn: Some(Template::default_generic_failure),
11480- list: &list,
11481- context: minijinja::context! {
11482- list => &list,
11483- subject => format!("Your post to {} was deferred.", list.id),
11484- details => &reason,
11485- },
11486- queue: Queue::Out,
11487- comment: format!("PostAction::Defer {{ reason: {} }}", reason)
11488- .into(),
11489- },
11490- std::iter::once(Cow::Borrowed(f)),
11491- )?;
11492- }
11493- self.insert_to_queue(QueueEntry::new(
11494- Queue::Deferred,
11495- Some(list.pk),
11496- Some(Cow::Borrowed(&post_env)),
11497- &bytes,
11498- Some(format!("PostAction::Defer {{ reason: {} }}", reason)),
11499- )?)?;
11500- return Ok(());
11501- }
11502- PostAction::Hold => {
11503- trace!("PostAction::Hold");
11504- self.insert_to_queue(QueueEntry::new(
11505- Queue::Hold,
11506- Some(list.pk),
11507- Some(Cow::Borrowed(&post_env)),
11508- &bytes,
11509- Some("PostAction::Hold".to_string()),
11510- )?)?;
11511- return Ok(());
11512- }
11513- }
11514- }
11515-
11516- Ok(())
11517- }
11518-
11519- /// Process a new mailing list request.
11520- pub fn request(
11521- &self,
11522- list: &DbVal<MailingList>,
11523- request: ListRequest,
11524- env: &Envelope,
11525- raw: &[u8],
11526- ) -> Result<()> {
11527- match request {
11528- ListRequest::Help => {
11529- trace!(
11530- "help action for addresses {:?} in list {}",
11531- env.from(),
11532- list
11533- );
11534- let subscription_policy = self.list_subscription_policy(list.pk)?;
11535- let post_policy = self.list_post_policy(list.pk)?;
11536- let subject = format!("Help for {}", list.name);
11537- let details = list
11538- .generate_help_email(post_policy.as_deref(), subscription_policy.as_deref());
11539- for f in env.from() {
11540- self.send_reply_with_list_template(
11541- TemplateRenderContext {
11542- template: Template::GENERIC_HELP,
11543- default_fn: Some(Template::default_generic_help),
11544- list,
11545- context: minijinja::context! {
11546- list => &list,
11547- subject => &subject,
11548- details => &details,
11549- },
11550- queue: Queue::Out,
11551- comment: "Help request".into(),
11552- },
11553- std::iter::once(Cow::Borrowed(f)),
11554- )?;
11555- }
11556- }
11557- ListRequest::Subscribe => {
11558- trace!(
11559- "subscribe action for addresses {:?} in list {}",
11560- env.from(),
11561- list
11562- );
11563- let subscription_policy = self.list_subscription_policy(list.pk)?;
11564- let approval_needed = subscription_policy
11565- .as_ref()
11566- .map(|p| !p.open)
11567- .unwrap_or(false);
11568- for f in env.from() {
11569- let email_from = f.get_email();
11570- if self
11571- .list_subscription_by_address(list.pk, &email_from)
11572- .is_ok()
11573- {
11574- /* send error notice to e-mail sender */
11575- self.send_reply_with_list_template(
11576- TemplateRenderContext {
11577- template: Template::GENERIC_FAILURE,
11578- default_fn: Some(Template::default_generic_failure),
11579- list,
11580- context: minijinja::context! {
11581- list => &list,
11582- subject => format!("You are already subscribed to {}.", list.id),
11583- details => "No action has been taken since you are already subscribed to the list.",
11584- },
11585- queue: Queue::Out,
11586- comment: format!("Address {} is already subscribed to list {}", f, list.id).into(),
11587- },
11588- std::iter::once(Cow::Borrowed(f)),
11589- )?;
11590- continue;
11591- }
11592-
11593- let subscription = ListSubscription {
11594- pk: 0,
11595- list: list.pk,
11596- address: f.get_email(),
11597- account: None,
11598- name: f.get_display_name(),
11599- digest: false,
11600- hide_address: false,
11601- receive_duplicates: true,
11602- receive_own_posts: false,
11603- receive_confirmation: true,
11604- enabled: !approval_needed,
11605- verified: true,
11606- };
11607- if approval_needed {
11608- match self.add_candidate_subscription(list.pk, subscription) {
11609- Ok(v) => {
11610- let list_owners = self.list_owners(list.pk)?;
11611- self.send_reply_with_list_template(
11612- TemplateRenderContext {
11613- template: Template::SUBSCRIPTION_REQUEST_NOTICE_OWNER,
11614- default_fn: Some(
11615- Template::default_subscription_request_owner,
11616- ),
11617- list,
11618- context: minijinja::context! {
11619- list => &list,
11620- candidate => &v,
11621- },
11622- queue: Queue::Out,
11623- comment: Template::SUBSCRIPTION_REQUEST_NOTICE_OWNER.into(),
11624- },
11625- list_owners.iter().map(|owner| Cow::Owned(owner.address())),
11626- )?;
11627- }
11628- Err(err) => {
11629- log::error!(
11630- "Could not create candidate subscription for {f:?}: {err}"
11631- );
11632- /* send error notice to e-mail sender */
11633- self.send_reply_with_list_template(
11634- TemplateRenderContext {
11635- template: Template::GENERIC_FAILURE,
11636- default_fn: Some(Template::default_generic_failure),
11637- list,
11638- context: minijinja::context! {
11639- list => &list,
11640- },
11641- queue: Queue::Out,
11642- comment: format!(
11643- "Could not create candidate subscription for {f:?}: \
11644- {err}"
11645- )
11646- .into(),
11647- },
11648- std::iter::once(Cow::Borrowed(f)),
11649- )?;
11650-
11651- /* send error details to list owners */
11652-
11653- let list_owners = self.list_owners(list.pk)?;
11654- self.send_reply_with_list_template(
11655- TemplateRenderContext {
11656- template: Template::ADMIN_NOTICE,
11657- default_fn: Some(Template::default_admin_notice),
11658- list,
11659- context: minijinja::context! {
11660- list => &list,
11661- details => err.to_string(),
11662- },
11663- queue: Queue::Out,
11664- comment: format!(
11665- "Could not create candidate subscription for {f:?}: \
11666- {err}"
11667- )
11668- .into(),
11669- },
11670- list_owners.iter().map(|owner| Cow::Owned(owner.address())),
11671- )?;
11672- }
11673- }
11674- } else if let Err(err) = self.add_subscription(list.pk, subscription) {
11675- log::error!("Could not create subscription for {f:?}: {err}");
11676-
11677- /* send error notice to e-mail sender */
11678-
11679- self.send_reply_with_list_template(
11680- TemplateRenderContext {
11681- template: Template::GENERIC_FAILURE,
11682- default_fn: Some(Template::default_generic_failure),
11683- list,
11684- context: minijinja::context! {
11685- list => &list,
11686- },
11687- queue: Queue::Out,
11688- comment: format!("Could not create subscription for {f:?}: {err}")
11689- .into(),
11690- },
11691- std::iter::once(Cow::Borrowed(f)),
11692- )?;
11693-
11694- /* send error details to list owners */
11695-
11696- let list_owners = self.list_owners(list.pk)?;
11697- self.send_reply_with_list_template(
11698- TemplateRenderContext {
11699- template: Template::ADMIN_NOTICE,
11700- default_fn: Some(Template::default_admin_notice),
11701- list,
11702- context: minijinja::context! {
11703- list => &list,
11704- details => err.to_string(),
11705- },
11706- queue: Queue::Out,
11707- comment: format!("Could not create subscription for {f:?}: {err}")
11708- .into(),
11709- },
11710- list_owners.iter().map(|owner| Cow::Owned(owner.address())),
11711- )?;
11712- } else {
11713- self.send_subscription_confirmation(list, f)?;
11714- }
11715- }
11716- }
11717- ListRequest::Unsubscribe => {
11718- trace!(
11719- "unsubscribe action for addresses {:?} in list {}",
11720- env.from(),
11721- list
11722- );
11723- for f in env.from() {
11724- if let Err(err) = self.remove_subscription(list.pk, &f.get_email()) {
11725- log::error!("Could not unsubscribe {f:?}: {err}");
11726- /* send error notice to e-mail sender */
11727-
11728- self.send_reply_with_list_template(
11729- TemplateRenderContext {
11730- template: Template::GENERIC_FAILURE,
11731- default_fn: Some(Template::default_generic_failure),
11732- list,
11733- context: minijinja::context! {
11734- list => &list,
11735- },
11736- queue: Queue::Out,
11737- comment: format!("Could not unsubscribe {f:?}: {err}").into(),
11738- },
11739- std::iter::once(Cow::Borrowed(f)),
11740- )?;
11741-
11742- /* send error details to list owners */
11743-
11744- let list_owners = self.list_owners(list.pk)?;
11745- self.send_reply_with_list_template(
11746- TemplateRenderContext {
11747- template: Template::ADMIN_NOTICE,
11748- default_fn: Some(Template::default_admin_notice),
11749- list,
11750- context: minijinja::context! {
11751- list => &list,
11752- details => err.to_string(),
11753- },
11754- queue: Queue::Out,
11755- comment: format!("Could not unsubscribe {f:?}: {err}").into(),
11756- },
11757- list_owners.iter().map(|owner| Cow::Owned(owner.address())),
11758- )?;
11759- } else {
11760- self.send_unsubscription_confirmation(list, f)?;
11761- }
11762- }
11763- }
11764- ListRequest::Other(ref req) if req == "owner" => {
11765- trace!(
11766- "list-owner mail action for addresses {:?} in list {}",
11767- env.from(),
11768- list
11769- );
11770- return Err("list-owner emails are not implemented yet.".into());
11771- //FIXME: mail to list-owner
11772- /*
11773- for _owner in self.list_owners(list.pk)? {
11774- self.insert_to_queue(
11775- Queue::Out,
11776- Some(list.pk),
11777- None,
11778- draft.finalise()?.as_bytes(),
11779- "list-owner-forward".to_string(),
11780- )?;
11781- }
11782- */
11783- }
11784- ListRequest::Other(ref req) if req.trim().eq_ignore_ascii_case("password") => {
11785- trace!(
11786- "list-request password set action for addresses {:?} in list {list}",
11787- env.from(),
11788- );
11789- let body = env.body_bytes(raw);
11790- let password = body.text();
11791- // TODO: validate SSH public key with `ssh-keygen`.
11792- for f in env.from() {
11793- let email_from = f.get_email();
11794- if let Ok(sub) = self.list_subscription_by_address(list.pk, &email_from) {
11795- match self.account_by_address(&email_from)? {
11796- Some(_acc) => {
11797- let changeset = AccountChangeset {
11798- address: email_from.clone(),
11799- name: None,
11800- public_key: None,
11801- password: Some(password.clone()),
11802- enabled: None,
11803- };
11804- self.update_account(changeset)?;
11805- }
11806- None => {
11807- // Create new account.
11808- self.add_account(Account {
11809- pk: 0,
11810- name: sub.name.clone(),
11811- address: sub.address.clone(),
11812- public_key: None,
11813- password: password.clone(),
11814- enabled: sub.enabled,
11815- })?;
11816- }
11817- }
11818- }
11819- }
11820- }
11821- ListRequest::RetrieveMessages(ref message_ids) => {
11822- trace!(
11823- "retrieve messages {message_ids:?} action for addresses {:?} in list {list}",
11824- env.from(),
11825- );
11826- return Err("message retrievals are not implemented yet.".into());
11827- }
11828- ListRequest::RetrieveArchive(ref from, ref to) => {
11829- trace!(
11830- "retrieve archive action from {from:?} to {to:?} for addresses {:?} in list \
11831- {list}",
11832- env.from(),
11833- );
11834- return Err("message retrievals are not implemented yet.".into());
11835- }
11836- ListRequest::ChangeSetting(ref setting, ref toggle) => {
11837- trace!(
11838- "change setting {setting}, request with value {toggle:?} for addresses {:?} \
11839- in list {list}",
11840- env.from(),
11841- );
11842- return Err("setting digest options via e-mail is not implemented yet.".into());
11843- }
11844- ListRequest::Other(ref req) => {
11845- trace!(
11846- "unknown request action {req} for addresses {:?} in list {list}",
11847- env.from(),
11848- );
11849- return Err(format!("Unknown request {req}.").into());
11850- }
11851- }
11852- Ok(())
11853- }
11854-
11855- /// Fetch all year and month values for which at least one post exists in
11856- /// `yyyy-mm` format.
11857- pub fn months(&self, list_pk: i64) -> Result<Vec<String>> {
11858- let mut stmt = self.connection.prepare(
11859- "SELECT DISTINCT strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') FROM post \
11860- WHERE list = ?;",
11861- )?;
11862- let months_iter = stmt.query_map([list_pk], |row| {
11863- let val: String = row.get(0)?;
11864- Ok(val)
11865- })?;
11866-
11867- let mut ret = vec![];
11868- for month in months_iter {
11869- let month = month?;
11870- ret.push(month);
11871- }
11872- Ok(ret)
11873- }
11874-
11875- /// Find a post by its `Message-ID` email header.
11876- pub fn list_post_by_message_id(
11877- &self,
11878- list_pk: i64,
11879- message_id: &str,
11880- ) -> Result<Option<DbVal<Post>>> {
11881- let mut stmt = self.connection.prepare(
11882- "SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
11883- FROM post WHERE list = ?1 AND (message_id = ?2 OR concat('<', ?2, '>') = message_id);",
11884- )?;
11885- let ret = stmt
11886- .query_row(rusqlite::params![&list_pk, &message_id], |row| {
11887- let pk = row.get("pk")?;
11888- Ok(DbVal(
11889- Post {
11890- pk,
11891- list: row.get("list")?,
11892- envelope_from: row.get("envelope_from")?,
11893- address: row.get("address")?,
11894- message_id: row.get("message_id")?,
11895- message: row.get("message")?,
11896- timestamp: row.get("timestamp")?,
11897- datetime: row.get("datetime")?,
11898- month_year: row.get("month_year")?,
11899- },
11900- pk,
11901- ))
11902- })
11903- .optional()?;
11904-
11905- Ok(ret)
11906- }
11907-
11908- /// Helper function to send a template reply.
11909- pub fn send_reply_with_list_template<'ctx, F: Fn() -> Template>(
11910- &self,
11911- render_context: TemplateRenderContext<'ctx, F>,
11912- recipients: impl Iterator<Item = Cow<'ctx, melib::Address>>,
11913- ) -> Result<()> {
11914- let TemplateRenderContext {
11915- template,
11916- default_fn,
11917- list,
11918- context,
11919- queue,
11920- comment,
11921- } = render_context;
11922-
11923- let post_policy = self.list_post_policy(list.pk)?;
11924- let subscription_policy = self.list_subscription_policy(list.pk)?;
11925-
11926- let templ = self
11927- .fetch_template(template, Some(list.pk))?
11928- .map(DbVal::into_inner)
11929- .or_else(|| default_fn.map(|f| f()))
11930- .ok_or_else(|| -> crate::Error {
11931- format!("Template with name {template:?} was not found.").into()
11932- })?;
11933-
11934- let mut draft = templ.render(context)?;
11935- draft
11936- .headers
11937- .insert(melib::HeaderName::FROM, list.request_subaddr());
11938- for addr in recipients {
11939- let mut draft = draft.clone();
11940- draft
11941- .headers
11942- .insert(melib::HeaderName::TO, addr.to_string());
11943- list.insert_headers(
11944- &mut draft,
11945- post_policy.as_deref(),
11946- subscription_policy.as_deref(),
11947- );
11948- self.insert_to_queue(QueueEntry::new(
11949- queue,
11950- Some(list.pk),
11951- None,
11952- draft.finalise()?.as_bytes(),
11953- Some(comment.to_string()),
11954- )?)?;
11955- }
11956- Ok(())
11957- }
11958-
11959- /// Send subscription confirmation.
11960- pub fn send_subscription_confirmation(
11961- &self,
11962- list: &DbVal<MailingList>,
11963- address: &melib::Address,
11964- ) -> Result<()> {
11965- log::trace!(
11966- "Added subscription to list {list:?} for address {address:?}, sending confirmation."
11967- );
11968- self.send_reply_with_list_template(
11969- TemplateRenderContext {
11970- template: Template::SUBSCRIPTION_CONFIRMATION,
11971- default_fn: Some(Template::default_subscription_confirmation),
11972- list,
11973- context: minijinja::context! {
11974- list => &list,
11975- },
11976- queue: Queue::Out,
11977- comment: Template::SUBSCRIPTION_CONFIRMATION.into(),
11978- },
11979- std::iter::once(Cow::Borrowed(address)),
11980- )
11981- }
11982-
11983- /// Send unsubscription confirmation.
11984- pub fn send_unsubscription_confirmation(
11985- &self,
11986- list: &DbVal<MailingList>,
11987- address: &melib::Address,
11988- ) -> Result<()> {
11989- log::trace!(
11990- "Removed subscription to list {list:?} for address {address:?}, sending confirmation."
11991- );
11992- self.send_reply_with_list_template(
11993- TemplateRenderContext {
11994- template: Template::UNSUBSCRIPTION_CONFIRMATION,
11995- default_fn: Some(Template::default_unsubscription_confirmation),
11996- list,
11997- context: minijinja::context! {
11998- list => &list,
11999- },
12000- queue: Queue::Out,
12001- comment: Template::UNSUBSCRIPTION_CONFIRMATION.into(),
12002- },
12003- std::iter::once(Cow::Borrowed(address)),
12004- )
12005- }
12006- }
12007-
12008- /// Helper type for [`Connection::send_reply_with_list_template`].
12009- #[derive(Debug)]
12010- pub struct TemplateRenderContext<'ctx, F: Fn() -> Template> {
12011- /// Template name.
12012- pub template: &'ctx str,
12013- /// If template is not found, call a function that returns one.
12014- pub default_fn: Option<F>,
12015- /// The pertinent list.
12016- pub list: &'ctx DbVal<MailingList>,
12017- /// [`minijinja`]'s template context.
12018- pub context: minijinja::value::Value,
12019- /// Destination queue in the database.
12020- pub queue: Queue,
12021- /// Comment for the queue entry in the database.
12022- pub comment: Cow<'static, str>,
12023- }
12024 diff --git a/core/src/queue.rs b/core/src/queue.rs
12025deleted file mode 100644
12026index 25311fc..0000000
12027--- a/core/src/queue.rs
12028+++ /dev/null
12029 @@ -1,370 +0,0 @@
12030- /*
12031- * This file is part of mailpot
12032- *
12033- * Copyright 2020 - Manos Pitsidianakis
12034- *
12035- * This program is free software: you can redistribute it and/or modify
12036- * it under the terms of the GNU Affero General Public License as
12037- * published by the Free Software Foundation, either version 3 of the
12038- * License, or (at your option) any later version.
12039- *
12040- * This program is distributed in the hope that it will be useful,
12041- * but WITHOUT ANY WARRANTY; without even the implied warranty of
12042- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12043- * GNU Affero General Public License for more details.
12044- *
12045- * You should have received a copy of the GNU Affero General Public License
12046- * along with this program. If not, see <https://www.gnu.org/licenses/>.
12047- */
12048-
12049- //! # Queues
12050-
12051- use std::borrow::Cow;
12052-
12053- use melib::Envelope;
12054-
12055- use crate::{errors::*, models::DbVal, Connection, DateTime};
12056-
12057- /// In-database queues of mail.
12058- #[derive(Copy, Clone, Eq, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
12059- #[serde(rename_all = "kebab-case")]
12060- pub enum Queue {
12061- /// Messages that have been received but not yet processed, await
12062- /// processing in the `maildrop` queue. Messages can be added to the
12063- /// `maildrop` queue even when mailpot is not running.
12064- Maildrop,
12065- /// List administrators may introduce rules for emails to be placed
12066- /// indefinitely in the `hold` queue. Messages placed in the `hold`
12067- /// queue stay there until the administrator intervenes. No periodic
12068- /// delivery attempts are made for messages in the `hold` queue.
12069- Hold,
12070- /// When all the deliverable recipients for a message are delivered, and for
12071- /// some recipients delivery failed for a transient reason (it might
12072- /// succeed later), the message is placed in the `deferred` queue.
12073- Deferred,
12074- /// Invalid received or generated e-mail saved for debug and troubleshooting
12075- /// reasons.
12076- Corrupt,
12077- /// Emails that must be sent as soon as possible.
12078- Out,
12079- /// Error queue
12080- Error,
12081- }
12082-
12083- impl std::str::FromStr for Queue {
12084- type Err = Error;
12085-
12086- fn from_str(s: &str) -> Result<Self> {
12087- Ok(match s.trim() {
12088- s if s.eq_ignore_ascii_case(stringify!(Maildrop)) => Self::Maildrop,
12089- s if s.eq_ignore_ascii_case(stringify!(Hold)) => Self::Hold,
12090- s if s.eq_ignore_ascii_case(stringify!(Deferred)) => Self::Deferred,
12091- s if s.eq_ignore_ascii_case(stringify!(Corrupt)) => Self::Corrupt,
12092- s if s.eq_ignore_ascii_case(stringify!(Out)) => Self::Out,
12093- s if s.eq_ignore_ascii_case(stringify!(Error)) => Self::Error,
12094- other => return Err(Error::new_external(format!("Invalid Queue name: {other}."))),
12095- })
12096- }
12097- }
12098-
12099- impl Queue {
12100- /// Returns the name of the queue used in the database schema.
12101- pub const fn as_str(&self) -> &'static str {
12102- match self {
12103- Self::Maildrop => "maildrop",
12104- Self::Hold => "hold",
12105- Self::Deferred => "deferred",
12106- Self::Corrupt => "corrupt",
12107- Self::Out => "out",
12108- Self::Error => "error",
12109- }
12110- }
12111-
12112- /// Returns all possible variants as `&'static str`
12113- pub const fn possible_values() -> &'static [&'static str] {
12114- const VALUES: &[&str] = &[
12115- Queue::Maildrop.as_str(),
12116- Queue::Hold.as_str(),
12117- Queue::Deferred.as_str(),
12118- Queue::Corrupt.as_str(),
12119- Queue::Out.as_str(),
12120- Queue::Error.as_str(),
12121- ];
12122- VALUES
12123- }
12124- }
12125-
12126- impl std::fmt::Display for Queue {
12127- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
12128- write!(fmt, "{}", self.as_str())
12129- }
12130- }
12131-
12132- /// A queue entry.
12133- #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
12134- pub struct QueueEntry {
12135- /// Database primary key.
12136- pub pk: i64,
12137- /// Owner queue.
12138- pub queue: Queue,
12139- /// Related list foreign key, optional.
12140- pub list: Option<i64>,
12141- /// Entry comment, optional.
12142- pub comment: Option<String>,
12143- /// Entry recipients in rfc5322 format.
12144- pub to_addresses: String,
12145- /// Entry submitter in rfc5322 format.
12146- pub from_address: String,
12147- /// Entry subject.
12148- pub subject: String,
12149- /// Entry Message-ID in rfc5322 format.
12150- pub message_id: String,
12151- /// Message in rfc5322 format as bytes.
12152- pub message: Vec<u8>,
12153- /// Unix timestamp of date.
12154- pub timestamp: u64,
12155- /// Datetime as string.
12156- pub datetime: DateTime,
12157- }
12158-
12159- impl std::fmt::Display for QueueEntry {
12160- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
12161- write!(fmt, "{:?}", self)
12162- }
12163- }
12164-
12165- impl std::fmt::Debug for QueueEntry {
12166- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
12167- fmt.debug_struct(stringify!(QueueEntry))
12168- .field("pk", &self.pk)
12169- .field("queue", &self.queue)
12170- .field("list", &self.list)
12171- .field("comment", &self.comment)
12172- .field("to_addresses", &self.to_addresses)
12173- .field("from_address", &self.from_address)
12174- .field("subject", &self.subject)
12175- .field("message_id", &self.message_id)
12176- .field("message length", &self.message.len())
12177- .field(
12178- "message",
12179- &format!("{:.15}", String::from_utf8_lossy(&self.message)),
12180- )
12181- .field("timestamp", &self.timestamp)
12182- .field("datetime", &self.datetime)
12183- .finish()
12184- }
12185- }
12186-
12187- impl QueueEntry {
12188- /// Create new entry.
12189- pub fn new(
12190- queue: Queue,
12191- list: Option<i64>,
12192- env: Option<Cow<'_, Envelope>>,
12193- raw: &[u8],
12194- comment: Option<String>,
12195- ) -> Result<Self> {
12196- let env = env
12197- .map(Ok)
12198- .unwrap_or_else(|| melib::Envelope::from_bytes(raw, None).map(Cow::Owned))?;
12199- let now = chrono::offset::Utc::now();
12200- Ok(Self {
12201- pk: -1,
12202- list,
12203- queue,
12204- comment,
12205- to_addresses: env.field_to_to_string(),
12206- from_address: env.field_from_to_string(),
12207- subject: env.subject().to_string(),
12208- message_id: env.message_id().to_string(),
12209- message: raw.to_vec(),
12210- timestamp: now.timestamp() as u64,
12211- datetime: now,
12212- })
12213- }
12214- }
12215-
12216- impl Connection {
12217- /// Insert a received email into a queue.
12218- pub fn insert_to_queue(&self, mut entry: QueueEntry) -> Result<DbVal<QueueEntry>> {
12219- log::trace!("Inserting to queue: {entry}");
12220- let mut stmt = self.connection.prepare(
12221- "INSERT INTO queue(which, list, comment, to_addresses, from_address, subject, \
12222- message_id, message, timestamp, datetime) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
12223- RETURNING pk;",
12224- )?;
12225- let pk = stmt.query_row(
12226- rusqlite::params![
12227- entry.queue.as_str(),
12228- &entry.list,
12229- &entry.comment,
12230- &entry.to_addresses,
12231- &entry.from_address,
12232- &entry.subject,
12233- &entry.message_id,
12234- &entry.message,
12235- &entry.timestamp,
12236- &entry.datetime,
12237- ],
12238- |row| {
12239- let pk: i64 = row.get("pk")?;
12240- Ok(pk)
12241- },
12242- )?;
12243- entry.pk = pk;
12244- Ok(DbVal(entry, pk))
12245- }
12246-
12247- /// Fetch all queue entries.
12248- pub fn queue(&self, queue: Queue) -> Result<Vec<DbVal<QueueEntry>>> {
12249- let mut stmt = self
12250- .connection
12251- .prepare("SELECT * FROM queue WHERE which = ?;")?;
12252- let iter = stmt.query_map([&queue.as_str()], |row| {
12253- let pk = row.get::<_, i64>("pk")?;
12254- Ok(DbVal(
12255- QueueEntry {
12256- pk,
12257- queue,
12258- list: row.get::<_, Option<i64>>("list")?,
12259- comment: row.get::<_, Option<String>>("comment")?,
12260- to_addresses: row.get::<_, String>("to_addresses")?,
12261- from_address: row.get::<_, String>("from_address")?,
12262- subject: row.get::<_, String>("subject")?,
12263- message_id: row.get::<_, String>("message_id")?,
12264- message: row.get::<_, Vec<u8>>("message")?,
12265- timestamp: row.get::<_, u64>("timestamp")?,
12266- datetime: row.get::<_, DateTime>("datetime")?,
12267- },
12268- pk,
12269- ))
12270- })?;
12271-
12272- let mut ret = vec![];
12273- for item in iter {
12274- let item = item?;
12275- ret.push(item);
12276- }
12277- Ok(ret)
12278- }
12279-
12280- /// Delete queue entries returning the deleted values.
12281- pub fn delete_from_queue(&self, queue: Queue, index: Vec<i64>) -> Result<Vec<QueueEntry>> {
12282- let tx = self.savepoint(Some(stringify!(delete_from_queue)))?;
12283-
12284- let cl = |row: &rusqlite::Row<'_>| {
12285- Ok(QueueEntry {
12286- pk: -1,
12287- queue,
12288- list: row.get::<_, Option<i64>>("list")?,
12289- comment: row.get::<_, Option<String>>("comment")?,
12290- to_addresses: row.get::<_, String>("to_addresses")?,
12291- from_address: row.get::<_, String>("from_address")?,
12292- subject: row.get::<_, String>("subject")?,
12293- message_id: row.get::<_, String>("message_id")?,
12294- message: row.get::<_, Vec<u8>>("message")?,
12295- timestamp: row.get::<_, u64>("timestamp")?,
12296- datetime: row.get::<_, DateTime>("datetime")?,
12297- })
12298- };
12299- let mut stmt = if index.is_empty() {
12300- tx.connection
12301- .prepare("DELETE FROM queue WHERE which = ? RETURNING *;")?
12302- } else {
12303- tx.connection
12304- .prepare("DELETE FROM queue WHERE which = ? AND pk IN rarray(?) RETURNING *;")?
12305- };
12306- let iter = if index.is_empty() {
12307- stmt.query_map([&queue.as_str()], cl)?
12308- } else {
12309- // Note: A `Rc<Vec<Value>>` must be used as the parameter.
12310- let index = std::rc::Rc::new(
12311- index
12312- .into_iter()
12313- .map(rusqlite::types::Value::from)
12314- .collect::<Vec<rusqlite::types::Value>>(),
12315- );
12316- stmt.query_map(rusqlite::params![queue.as_str(), index], cl)?
12317- };
12318-
12319- let mut ret = vec![];
12320- for item in iter {
12321- let item = item?;
12322- ret.push(item);
12323- }
12324- drop(stmt);
12325- tx.commit()?;
12326- Ok(ret)
12327- }
12328- }
12329-
12330- #[cfg(test)]
12331- mod tests {
12332- use super::*;
12333- use crate::*;
12334-
12335- #[test]
12336- fn test_queue_delete_array() {
12337- use tempfile::TempDir;
12338-
12339- let tmp_dir = TempDir::new().unwrap();
12340- let db_path = tmp_dir.path().join("mpot.db");
12341- let config = Configuration {
12342- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
12343- db_path,
12344- data_path: tmp_dir.path().to_path_buf(),
12345- administrators: vec![],
12346- };
12347-
12348- let db = Connection::open_or_create_db(config).unwrap().trusted();
12349- for i in 0..5 {
12350- db.insert_to_queue(
12351- QueueEntry::new(
12352- Queue::Hold,
12353- None,
12354- None,
12355- format!("Subject: testing\r\nMessage-Id: {i}@localhost\r\n\r\nHello\r\n")
12356- .as_bytes(),
12357- None,
12358- )
12359- .unwrap(),
12360- )
12361- .unwrap();
12362- }
12363- let entries = db.queue(Queue::Hold).unwrap();
12364- assert_eq!(entries.len(), 5);
12365- let out_entries = db.delete_from_queue(Queue::Out, vec![]).unwrap();
12366- assert_eq!(db.queue(Queue::Hold).unwrap().len(), 5);
12367- assert!(out_entries.is_empty());
12368- let deleted_entries = db.delete_from_queue(Queue::Hold, vec![]).unwrap();
12369- assert_eq!(deleted_entries.len(), 5);
12370- assert_eq!(
12371- &entries
12372- .iter()
12373- .cloned()
12374- .map(DbVal::into_inner)
12375- .map(|mut e| {
12376- e.pk = -1;
12377- e
12378- })
12379- .collect::<Vec<_>>(),
12380- &deleted_entries
12381- );
12382-
12383- for e in deleted_entries {
12384- db.insert_to_queue(e).unwrap();
12385- }
12386-
12387- let index = db
12388- .queue(Queue::Hold)
12389- .unwrap()
12390- .into_iter()
12391- .skip(2)
12392- .map(|e| e.pk())
12393- .take(2)
12394- .collect::<Vec<i64>>();
12395- let deleted_entries = db.delete_from_queue(Queue::Hold, index).unwrap();
12396- assert_eq!(deleted_entries.len(), 2);
12397- assert_eq!(db.queue(Queue::Hold).unwrap().len(), 3);
12398- }
12399- }
12400 diff --git a/core/src/schema.sql b/core/src/schema.sql
12401deleted file mode 100644
12402index 52e6d34..0000000
12403--- a/core/src/schema.sql
12404+++ /dev/null
12405 @@ -1,657 +0,0 @@
12406- PRAGMA foreign_keys = true;
12407- PRAGMA encoding = 'UTF-8';
12408-
12409- CREATE TABLE IF NOT EXISTS list (
12410- pk INTEGER PRIMARY KEY NOT NULL,
12411- name TEXT NOT NULL,
12412- id TEXT NOT NULL UNIQUE,
12413- address TEXT NOT NULL UNIQUE,
12414- owner_local_part TEXT,
12415- request_local_part TEXT,
12416- archive_url TEXT,
12417- description TEXT,
12418- topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
12419- created INTEGER NOT NULL DEFAULT (unixepoch()),
12420- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
12421- verify BOOLEAN CHECK (verify IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
12422- hidden BOOLEAN CHECK (hidden IN (0, 1)) NOT NULL DEFAULT 0,
12423- enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1
12424- );
12425-
12426- CREATE TABLE IF NOT EXISTS owner (
12427- pk INTEGER PRIMARY KEY NOT NULL,
12428- list INTEGER NOT NULL,
12429- address TEXT NOT NULL,
12430- name TEXT,
12431- created INTEGER NOT NULL DEFAULT (unixepoch()),
12432- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
12433- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
12434- );
12435-
12436- CREATE TABLE IF NOT EXISTS post_policy (
12437- pk INTEGER PRIMARY KEY NOT NULL,
12438- list INTEGER NOT NULL UNIQUE,
12439- announce_only BOOLEAN CHECK (announce_only IN (0, 1)) NOT NULL
12440- DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
12441- subscription_only BOOLEAN CHECK (subscription_only IN (0, 1)) NOT NULL
12442- DEFAULT 0,
12443- approval_needed BOOLEAN CHECK (approval_needed IN (0, 1)) NOT NULL
12444- DEFAULT 0,
12445- open BOOLEAN CHECK (open IN (0, 1)) NOT NULL DEFAULT 0,
12446- custom BOOLEAN CHECK (custom IN (0, 1)) NOT NULL DEFAULT 0,
12447- created INTEGER NOT NULL DEFAULT (unixepoch()),
12448- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
12449- CHECK((
12450- (custom) OR ((
12451- (open) OR ((
12452- (approval_needed) OR ((
12453- (announce_only) OR (subscription_only)
12454- )
12455- AND NOT
12456- (
12457- (announce_only) AND (subscription_only)
12458- ))
12459- )
12460- AND NOT
12461- (
12462- (approval_needed) AND ((
12463- (announce_only) OR (subscription_only)
12464- )
12465- AND NOT
12466- (
12467- (announce_only) AND (subscription_only)
12468- ))
12469- ))
12470- )
12471- AND NOT
12472- (
12473- (open) AND ((
12474- (approval_needed) OR ((
12475- (announce_only) OR (subscription_only)
12476- )
12477- AND NOT
12478- (
12479- (announce_only) AND (subscription_only)
12480- ))
12481- )
12482- AND NOT
12483- (
12484- (approval_needed) AND ((
12485- (announce_only) OR (subscription_only)
12486- )
12487- AND NOT
12488- (
12489- (announce_only) AND (subscription_only)
12490- ))
12491- ))
12492- ))
12493- )
12494- AND NOT
12495- (
12496- (custom) AND ((
12497- (open) OR ((
12498- (approval_needed) OR ((
12499- (announce_only) OR (subscription_only)
12500- )
12501- AND NOT
12502- (
12503- (announce_only) AND (subscription_only)
12504- ))
12505- )
12506- AND NOT
12507- (
12508- (approval_needed) AND ((
12509- (announce_only) OR (subscription_only)
12510- )
12511- AND NOT
12512- (
12513- (announce_only) AND (subscription_only)
12514- ))
12515- ))
12516- )
12517- AND NOT
12518- (
12519- (open) AND ((
12520- (approval_needed) OR ((
12521- (announce_only) OR (subscription_only)
12522- )
12523- AND NOT
12524- (
12525- (announce_only) AND (subscription_only)
12526- ))
12527- )
12528- AND NOT
12529- (
12530- (approval_needed) AND ((
12531- (announce_only) OR (subscription_only)
12532- )
12533- AND NOT
12534- (
12535- (announce_only) AND (subscription_only)
12536- ))
12537- ))
12538- ))
12539- )),
12540- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
12541- );
12542-
12543- CREATE TABLE IF NOT EXISTS subscription_policy (
12544- pk INTEGER PRIMARY KEY NOT NULL,
12545- list INTEGER NOT NULL UNIQUE,
12546- send_confirmation BOOLEAN CHECK (send_confirmation IN (0, 1)) NOT NULL
12547- DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
12548- open BOOLEAN CHECK (open IN (0, 1)) NOT NULL DEFAULT 0,
12549- manual BOOLEAN CHECK (manual IN (0, 1)) NOT NULL DEFAULT 0,
12550- request BOOLEAN CHECK (request IN (0, 1)) NOT NULL DEFAULT 0,
12551- custom BOOLEAN CHECK (custom IN (0, 1)) NOT NULL DEFAULT 0,
12552- created INTEGER NOT NULL DEFAULT (unixepoch()),
12553- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
12554- CHECK((
12555- (open) OR ((
12556- (manual) OR ((
12557- (request) OR (custom)
12558- )
12559- AND NOT
12560- (
12561- (request) AND (custom)
12562- ))
12563- )
12564- AND NOT
12565- (
12566- (manual) AND ((
12567- (request) OR (custom)
12568- )
12569- AND NOT
12570- (
12571- (request) AND (custom)
12572- ))
12573- ))
12574- )
12575- AND NOT
12576- (
12577- (open) AND ((
12578- (manual) OR ((
12579- (request) OR (custom)
12580- )
12581- AND NOT
12582- (
12583- (request) AND (custom)
12584- ))
12585- )
12586- AND NOT
12587- (
12588- (manual) AND ((
12589- (request) OR (custom)
12590- )
12591- AND NOT
12592- (
12593- (request) AND (custom)
12594- ))
12595- ))
12596- )),
12597- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
12598- );
12599-
12600- CREATE TABLE IF NOT EXISTS subscription (
12601- pk INTEGER PRIMARY KEY NOT NULL,
12602- list INTEGER NOT NULL,
12603- address TEXT NOT NULL,
12604- name TEXT,
12605- account INTEGER,
12606- enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL
12607- DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
12608- verified BOOLEAN CHECK (verified IN (0, 1)) NOT NULL
12609- DEFAULT 1,
12610- digest BOOLEAN CHECK (digest IN (0, 1)) NOT NULL
12611- DEFAULT 0,
12612- hide_address BOOLEAN CHECK (hide_address IN (0, 1)) NOT NULL
12613- DEFAULT 0,
12614- receive_duplicates BOOLEAN CHECK (receive_duplicates IN (0, 1)) NOT NULL
12615- DEFAULT 1,
12616- receive_own_posts BOOLEAN CHECK (receive_own_posts IN (0, 1)) NOT NULL
12617- DEFAULT 0,
12618- receive_confirmation BOOLEAN CHECK (receive_confirmation IN (0, 1)) NOT NULL
12619- DEFAULT 1,
12620- last_digest INTEGER NOT NULL DEFAULT (unixepoch()),
12621- created INTEGER NOT NULL DEFAULT (unixepoch()),
12622- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
12623- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
12624- FOREIGN KEY (account) REFERENCES account(pk) ON DELETE SET NULL,
12625- UNIQUE (list, address) ON CONFLICT ROLLBACK
12626- );
12627-
12628- CREATE TABLE IF NOT EXISTS account (
12629- pk INTEGER PRIMARY KEY NOT NULL,
12630- name TEXT,
12631- address TEXT NOT NULL UNIQUE,
12632- public_key TEXT,
12633- password TEXT NOT NULL,
12634- enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
12635- created INTEGER NOT NULL DEFAULT (unixepoch()),
12636- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
12637- );
12638-
12639- CREATE TABLE IF NOT EXISTS candidate_subscription (
12640- pk INTEGER PRIMARY KEY NOT NULL,
12641- list INTEGER NOT NULL,
12642- address TEXT NOT NULL,
12643- name TEXT,
12644- accepted INTEGER UNIQUE,
12645- created INTEGER NOT NULL DEFAULT (unixepoch()),
12646- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
12647- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
12648- FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
12649- UNIQUE (list, address) ON CONFLICT ROLLBACK
12650- );
12651-
12652- CREATE TABLE IF NOT EXISTS post (
12653- pk INTEGER PRIMARY KEY NOT NULL,
12654- list INTEGER NOT NULL,
12655- envelope_from TEXT,
12656- address TEXT NOT NULL,
12657- message_id TEXT NOT NULL,
12658- message BLOB NOT NULL,
12659- headers_json TEXT,
12660- timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
12661- datetime TEXT NOT NULL DEFAULT (datetime()),
12662- created INTEGER NOT NULL DEFAULT (unixepoch())
12663- );
12664-
12665- CREATE TABLE IF NOT EXISTS template (
12666- pk INTEGER PRIMARY KEY NOT NULL,
12667- name TEXT NOT NULL,
12668- list INTEGER,
12669- subject TEXT,
12670- headers_json TEXT,
12671- body TEXT NOT NULL,
12672- created INTEGER NOT NULL DEFAULT (unixepoch()),
12673- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
12674- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
12675- UNIQUE (list, name) ON CONFLICT ROLLBACK
12676- );
12677-
12678- CREATE TABLE IF NOT EXISTS settings_json_schema (
12679- pk INTEGER PRIMARY KEY NOT NULL,
12680- id TEXT NOT NULL UNIQUE,
12681- value JSON NOT NULL CHECK (json_type(value) = 'object'),
12682- created INTEGER NOT NULL DEFAULT (unixepoch()),
12683- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
12684- );
12685-
12686- CREATE TABLE IF NOT EXISTS list_settings_json (
12687- pk INTEGER PRIMARY KEY NOT NULL,
12688- name TEXT NOT NULL,
12689- list INTEGER,
12690- value JSON NOT NULL CHECK (json_type(value) = 'object'),
12691- is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
12692- created INTEGER NOT NULL DEFAULT (unixepoch()),
12693- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
12694- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
12695- FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
12696- UNIQUE (list, name) ON CONFLICT ROLLBACK
12697- );
12698-
12699- CREATE TRIGGER
12700- IF NOT EXISTS is_valid_settings_json_on_update
12701- AFTER UPDATE OF value, name, is_valid ON list_settings_json
12702- FOR EACH ROW
12703- BEGIN
12704- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
12705- UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
12706- END;
12707-
12708- CREATE TRIGGER
12709- IF NOT EXISTS is_valid_settings_json_on_insert
12710- AFTER INSERT ON list_settings_json
12711- FOR EACH ROW
12712- BEGIN
12713- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
12714- UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
12715- END;
12716-
12717- CREATE TRIGGER
12718- IF NOT EXISTS invalidate_settings_json_on_schema_update
12719- AFTER UPDATE OF value, id ON settings_json_schema
12720- FOR EACH ROW
12721- BEGIN
12722- UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
12723- END;
12724-
12725- -- # Queues
12726- --
12727- -- ## The "maildrop" queue
12728- --
12729- -- Messages that have been submitted but not yet processed, await processing
12730- -- in the "maildrop" queue. Messages can be added to the "maildrop" queue
12731- -- even when mailpot is not running.
12732- --
12733- -- ## The "deferred" queue
12734- --
12735- -- When all the deliverable recipients for a message are delivered, and for
12736- -- some recipients delivery failed for a transient reason (it might succeed
12737- -- later), the message is placed in the "deferred" queue.
12738- --
12739- -- ## The "hold" queue
12740- --
12741- -- List administrators may introduce rules for emails to be placed
12742- -- indefinitely in the "hold" queue. Messages placed in the "hold" queue stay
12743- -- there until the administrator intervenes. No periodic delivery attempts
12744- -- are made for messages in the "hold" queue.
12745-
12746- -- ## The "out" queue
12747- --
12748- -- Emails that must be sent as soon as possible.
12749- CREATE TABLE IF NOT EXISTS queue (
12750- pk INTEGER PRIMARY KEY NOT NULL,
12751- which TEXT
12752- CHECK (
12753- which IN
12754- ('maildrop',
12755- 'hold',
12756- 'deferred',
12757- 'corrupt',
12758- 'error',
12759- 'out')
12760- ) NOT NULL,
12761- list INTEGER,
12762- comment TEXT,
12763- to_addresses TEXT NOT NULL,
12764- from_address TEXT NOT NULL,
12765- subject TEXT NOT NULL,
12766- message_id TEXT NOT NULL,
12767- message BLOB NOT NULL,
12768- timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
12769- datetime TEXT NOT NULL DEFAULT (datetime()),
12770- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
12771- UNIQUE (to_addresses, message_id) ON CONFLICT ROLLBACK
12772- );
12773-
12774- CREATE TABLE IF NOT EXISTS bounce (
12775- pk INTEGER PRIMARY KEY NOT NULL,
12776- subscription INTEGER NOT NULL UNIQUE,
12777- count INTEGER NOT NULL DEFAULT 0,
12778- last_bounce TEXT NOT NULL DEFAULT (datetime()),
12779- FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
12780- );
12781-
12782- CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
12783- CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
12784- CREATE INDEX IF NOT EXISTS list_idx ON list(id);
12785- CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
12786-
12787- -- [tag:accept_candidate]: Update candidacy with 'subscription' foreign key on
12788- -- 'subscription' insert.
12789- CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
12790- FOR EACH ROW
12791- BEGIN
12792- UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
12793- WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
12794- END;
12795-
12796- -- [tag:verify_subscription_email]: If list settings require e-mail to be
12797- -- verified, update new subscription's 'verify' column value.
12798- CREATE TRIGGER IF NOT EXISTS verify_subscription_email AFTER INSERT ON subscription
12799- FOR EACH ROW
12800- BEGIN
12801- UPDATE subscription
12802- SET verified = 0, last_modified = unixepoch()
12803- WHERE
12804- subscription.pk = NEW.pk
12805- AND
12806- EXISTS
12807- (SELECT 1 FROM list WHERE pk = NEW.list AND verify = 1);
12808- END;
12809-
12810- -- [tag:add_account]: Update list subscription entries with 'account' foreign
12811- -- key, if addresses match.
12812- CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
12813- FOR EACH ROW
12814- BEGIN
12815- UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
12816- WHERE subscription.address = NEW.address;
12817- END;
12818-
12819- -- [tag:add_account_to_subscription]: When adding a new 'subscription', auto
12820- -- set 'account' value if there already exists an 'account' entry with the
12821- -- same address.
12822- CREATE TRIGGER IF NOT EXISTS add_account_to_subscription
12823- AFTER INSERT ON subscription
12824- FOR EACH ROW
12825- WHEN
12826- NEW.account IS NULL
12827- AND EXISTS (SELECT 1 FROM account WHERE address = NEW.address)
12828- BEGIN
12829- UPDATE subscription
12830- SET account = (SELECT pk FROM account WHERE address = NEW.address),
12831- last_modified = unixepoch()
12832- WHERE subscription.pk = NEW.pk;
12833- END;
12834-
12835-
12836- -- [tag:last_modified_list]: update last_modified on every change.
12837- CREATE TRIGGER
12838- IF NOT EXISTS last_modified_list
12839- AFTER UPDATE ON list
12840- FOR EACH ROW
12841- WHEN NEW.last_modified == OLD.last_modified
12842- BEGIN
12843- UPDATE list SET last_modified = unixepoch()
12844- WHERE pk = NEW.pk;
12845- END;
12846-
12847- -- [tag:last_modified_owner]: update last_modified on every change.
12848- CREATE TRIGGER
12849- IF NOT EXISTS last_modified_owner
12850- AFTER UPDATE ON owner
12851- FOR EACH ROW
12852- WHEN NEW.last_modified == OLD.last_modified
12853- BEGIN
12854- UPDATE owner SET last_modified = unixepoch()
12855- WHERE pk = NEW.pk;
12856- END;
12857-
12858- -- [tag:last_modified_post_policy]: update last_modified on every change.
12859- CREATE TRIGGER
12860- IF NOT EXISTS last_modified_post_policy
12861- AFTER UPDATE ON post_policy
12862- FOR EACH ROW
12863- WHEN NEW.last_modified == OLD.last_modified
12864- BEGIN
12865- UPDATE post_policy SET last_modified = unixepoch()
12866- WHERE pk = NEW.pk;
12867- END;
12868-
12869- -- [tag:last_modified_subscription_policy]: update last_modified on every change.
12870- CREATE TRIGGER
12871- IF NOT EXISTS last_modified_subscription_policy
12872- AFTER UPDATE ON subscription_policy
12873- FOR EACH ROW
12874- WHEN NEW.last_modified == OLD.last_modified
12875- BEGIN
12876- UPDATE subscription_policy SET last_modified = unixepoch()
12877- WHERE pk = NEW.pk;
12878- END;
12879-
12880- -- [tag:last_modified_subscription]: update last_modified on every change.
12881- CREATE TRIGGER
12882- IF NOT EXISTS last_modified_subscription
12883- AFTER UPDATE ON subscription
12884- FOR EACH ROW
12885- WHEN NEW.last_modified == OLD.last_modified
12886- BEGIN
12887- UPDATE subscription SET last_modified = unixepoch()
12888- WHERE pk = NEW.pk;
12889- END;
12890-
12891- -- [tag:last_modified_account]: update last_modified on every change.
12892- CREATE TRIGGER
12893- IF NOT EXISTS last_modified_account
12894- AFTER UPDATE ON account
12895- FOR EACH ROW
12896- WHEN NEW.last_modified == OLD.last_modified
12897- BEGIN
12898- UPDATE account SET last_modified = unixepoch()
12899- WHERE pk = NEW.pk;
12900- END;
12901-
12902- -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
12903- CREATE TRIGGER
12904- IF NOT EXISTS last_modified_candidate_subscription
12905- AFTER UPDATE ON candidate_subscription
12906- FOR EACH ROW
12907- WHEN NEW.last_modified == OLD.last_modified
12908- BEGIN
12909- UPDATE candidate_subscription SET last_modified = unixepoch()
12910- WHERE pk = NEW.pk;
12911- END;
12912-
12913- -- [tag:last_modified_template]: update last_modified on every change.
12914- CREATE TRIGGER
12915- IF NOT EXISTS last_modified_template
12916- AFTER UPDATE ON template
12917- FOR EACH ROW
12918- WHEN NEW.last_modified == OLD.last_modified
12919- BEGIN
12920- UPDATE template SET last_modified = unixepoch()
12921- WHERE pk = NEW.pk;
12922- END;
12923-
12924- -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
12925- CREATE TRIGGER
12926- IF NOT EXISTS last_modified_settings_json_schema
12927- AFTER UPDATE ON settings_json_schema
12928- FOR EACH ROW
12929- WHEN NEW.last_modified == OLD.last_modified
12930- BEGIN
12931- UPDATE settings_json_schema SET last_modified = unixepoch()
12932- WHERE pk = NEW.pk;
12933- END;
12934-
12935- -- [tag:last_modified_list_settings_json]: update last_modified on every change.
12936- CREATE TRIGGER
12937- IF NOT EXISTS last_modified_list_settings_json
12938- AFTER UPDATE ON list_settings_json
12939- FOR EACH ROW
12940- WHEN NEW.last_modified == OLD.last_modified
12941- BEGIN
12942- UPDATE list_settings_json SET last_modified = unixepoch()
12943- WHERE pk = NEW.pk;
12944- END;
12945-
12946- CREATE TRIGGER
12947- IF NOT EXISTS sort_topics_update_trigger
12948- AFTER UPDATE ON list
12949- FOR EACH ROW
12950- WHEN NEW.topics != OLD.topics
12951- BEGIN
12952- UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
12953- END;
12954-
12955- CREATE TRIGGER
12956- IF NOT EXISTS sort_topics_new_trigger
12957- AFTER INSERT ON list
12958- FOR EACH ROW
12959- BEGIN
12960- UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
12961- END;
12962-
12963-
12964- -- 005.data.sql
12965-
12966- INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
12967- "$schema": "http://json-schema.org/draft-07/schema",
12968- "$ref": "#/$defs/ArchivedAtLinkSettings",
12969- "$defs": {
12970- "ArchivedAtLinkSettings": {
12971- "title": "ArchivedAtLinkSettings",
12972- "description": "Settings for ArchivedAtLink message filter",
12973- "type": "object",
12974- "properties": {
12975- "template": {
12976- "title": "Jinja template for header value",
12977- "description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
12978- "examples": [
12979- "https://www.example.com/{{msg_id}}",
12980- "https://www.example.com/{{msg_id}}.html"
12981- ],
12982- "type": "string",
12983- "pattern": ".+[{][{]msg_id[}][}].*"
12984- },
12985- "preserve_carets": {
12986- "title": "Preserve carets of `Message-ID` in generated value",
12987- "type": "boolean",
12988- "default": false
12989- }
12990- },
12991- "required": [
12992- "template"
12993- ]
12994- }
12995- }
12996- }');
12997-
12998-
12999- -- 006.data.sql
13000-
13001- INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
13002- "$schema": "http://json-schema.org/draft-07/schema",
13003- "$ref": "#/$defs/AddSubjectTagPrefixSettings",
13004- "$defs": {
13005- "AddSubjectTagPrefixSettings": {
13006- "title": "AddSubjectTagPrefixSettings",
13007- "description": "Settings for AddSubjectTagPrefix message filter",
13008- "type": "object",
13009- "properties": {
13010- "enabled": {
13011- "title": "If true, the list subject prefix is added to post subjects.",
13012- "type": "boolean"
13013- }
13014- },
13015- "required": [
13016- "enabled"
13017- ]
13018- }
13019- }
13020- }');
13021-
13022-
13023- -- 007.data.sql
13024-
13025- INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
13026- "$schema": "http://json-schema.org/draft-07/schema",
13027- "$ref": "#/$defs/MimeRejectSettings",
13028- "$defs": {
13029- "MimeRejectSettings": {
13030- "title": "MimeRejectSettings",
13031- "description": "Settings for MimeReject message filter",
13032- "type": "object",
13033- "properties": {
13034- "enabled": {
13035- "title": "If true, list posts that contain mime types in the reject array are rejected.",
13036- "type": "boolean"
13037- },
13038- "reject": {
13039- "title": "Mime types to reject.",
13040- "type": "array",
13041- "minLength": 0,
13042- "items": { "$ref": "#/$defs/MimeType" }
13043- },
13044- "required": [
13045- "enabled"
13046- ]
13047- }
13048- },
13049- "MimeType": {
13050- "type": "string",
13051- "maxLength": 127,
13052- "minLength": 3,
13053- "uniqueItems": true,
13054- "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
13055- }
13056- }
13057- }');
13058-
13059-
13060- -- Set current schema version.
13061-
13062- PRAGMA user_version = 7;
13063 diff --git a/core/src/schema.sql.m4 b/core/src/schema.sql.m4
13064deleted file mode 100644
13065index c89fa8f..0000000
13066--- a/core/src/schema.sql.m4
13067+++ /dev/null
13068 @@ -1,359 +0,0 @@
13069- define(xor, `dnl
13070- (
13071- ($1) OR ($2)
13072- )
13073- AND NOT
13074- (
13075- ($1) AND ($2)
13076- )')dnl
13077- dnl
13078- dnl # Define boolean column types and defaults
13079- define(BOOLEAN_TYPE, `BOOLEAN CHECK ($1 IN (0, 1)) NOT NULL')dnl
13080- define(BOOLEAN_FALSE, `0')dnl
13081- define(BOOLEAN_TRUE, `1')dnl
13082- define(BOOLEAN_DOCS, ` -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1')dnl
13083- dnl
13084- dnl # defile comment functions
13085- dnl
13086- dnl # Write the string '['+'tag'+':'+... with a macro so that tagref check
13087- dnl # doesn't pick up on it as a duplicate.
13088- define(__TAG, `tag')dnl
13089- define(TAG, `['__TAG()`:$1]')dnl
13090- dnl
13091- dnl # define triggers
13092- define(update_last_modified, `
13093- -- 'TAG(last_modified_$1)`: update last_modified on every change.
13094- CREATE TRIGGER
13095- IF NOT EXISTS last_modified_$1
13096- AFTER UPDATE ON $1
13097- FOR EACH ROW
13098- WHEN NEW.last_modified == OLD.last_modified
13099- BEGIN
13100- UPDATE $1 SET last_modified = unixepoch()
13101- WHERE pk = NEW.pk;
13102- END;')dnl
13103- dnl
13104- PRAGMA foreign_keys = true;
13105- PRAGMA encoding = 'UTF-8';
13106-
13107- CREATE TABLE IF NOT EXISTS list (
13108- pk INTEGER PRIMARY KEY NOT NULL,
13109- name TEXT NOT NULL,
13110- id TEXT NOT NULL UNIQUE,
13111- address TEXT NOT NULL UNIQUE,
13112- owner_local_part TEXT,
13113- request_local_part TEXT,
13114- archive_url TEXT,
13115- description TEXT,
13116- topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
13117- created INTEGER NOT NULL DEFAULT (unixepoch()),
13118- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
13119- verify BOOLEAN_TYPE(verify) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
13120- hidden BOOLEAN_TYPE(hidden) DEFAULT BOOLEAN_FALSE(),
13121- enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE()
13122- );
13123-
13124- CREATE TABLE IF NOT EXISTS owner (
13125- pk INTEGER PRIMARY KEY NOT NULL,
13126- list INTEGER NOT NULL,
13127- address TEXT NOT NULL,
13128- name TEXT,
13129- created INTEGER NOT NULL DEFAULT (unixepoch()),
13130- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
13131- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
13132- );
13133-
13134- CREATE TABLE IF NOT EXISTS post_policy (
13135- pk INTEGER PRIMARY KEY NOT NULL,
13136- list INTEGER NOT NULL UNIQUE,
13137- announce_only BOOLEAN_TYPE(announce_only)
13138- DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
13139- subscription_only BOOLEAN_TYPE(subscription_only)
13140- DEFAULT BOOLEAN_FALSE(),
13141- approval_needed BOOLEAN_TYPE(approval_needed)
13142- DEFAULT BOOLEAN_FALSE(),
13143- open BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
13144- custom BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
13145- created INTEGER NOT NULL DEFAULT (unixepoch()),
13146- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
13147- CHECK(xor(custom, xor(open, xor(approval_needed, xor(announce_only, subscription_only))))),
13148- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
13149- );
13150-
13151- CREATE TABLE IF NOT EXISTS subscription_policy (
13152- pk INTEGER PRIMARY KEY NOT NULL,
13153- list INTEGER NOT NULL UNIQUE,
13154- send_confirmation BOOLEAN_TYPE(send_confirmation)
13155- DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
13156- open BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
13157- manual BOOLEAN_TYPE(manual) DEFAULT BOOLEAN_FALSE(),
13158- request BOOLEAN_TYPE(request) DEFAULT BOOLEAN_FALSE(),
13159- custom BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
13160- created INTEGER NOT NULL DEFAULT (unixepoch()),
13161- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
13162- CHECK(xor(open, xor(manual, xor(request, custom)))),
13163- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
13164- );
13165-
13166- CREATE TABLE IF NOT EXISTS subscription (
13167- pk INTEGER PRIMARY KEY NOT NULL,
13168- list INTEGER NOT NULL,
13169- address TEXT NOT NULL,
13170- name TEXT,
13171- account INTEGER,
13172- enabled BOOLEAN_TYPE(enabled)
13173- DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
13174- verified BOOLEAN_TYPE(verified)
13175- DEFAULT BOOLEAN_TRUE(),
13176- digest BOOLEAN_TYPE(digest)
13177- DEFAULT BOOLEAN_FALSE(),
13178- hide_address BOOLEAN_TYPE(hide_address)
13179- DEFAULT BOOLEAN_FALSE(),
13180- receive_duplicates BOOLEAN_TYPE(receive_duplicates)
13181- DEFAULT BOOLEAN_TRUE(),
13182- receive_own_posts BOOLEAN_TYPE(receive_own_posts)
13183- DEFAULT BOOLEAN_FALSE(),
13184- receive_confirmation BOOLEAN_TYPE(receive_confirmation)
13185- DEFAULT BOOLEAN_TRUE(),
13186- last_digest INTEGER NOT NULL DEFAULT (unixepoch()),
13187- created INTEGER NOT NULL DEFAULT (unixepoch()),
13188- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
13189- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
13190- FOREIGN KEY (account) REFERENCES account(pk) ON DELETE SET NULL,
13191- UNIQUE (list, address) ON CONFLICT ROLLBACK
13192- );
13193-
13194- CREATE TABLE IF NOT EXISTS account (
13195- pk INTEGER PRIMARY KEY NOT NULL,
13196- name TEXT,
13197- address TEXT NOT NULL UNIQUE,
13198- public_key TEXT,
13199- password TEXT NOT NULL,
13200- enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
13201- created INTEGER NOT NULL DEFAULT (unixepoch()),
13202- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
13203- );
13204-
13205- CREATE TABLE IF NOT EXISTS candidate_subscription (
13206- pk INTEGER PRIMARY KEY NOT NULL,
13207- list INTEGER NOT NULL,
13208- address TEXT NOT NULL,
13209- name TEXT,
13210- accepted INTEGER UNIQUE,
13211- created INTEGER NOT NULL DEFAULT (unixepoch()),
13212- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
13213- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
13214- FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
13215- UNIQUE (list, address) ON CONFLICT ROLLBACK
13216- );
13217-
13218- CREATE TABLE IF NOT EXISTS post (
13219- pk INTEGER PRIMARY KEY NOT NULL,
13220- list INTEGER NOT NULL,
13221- envelope_from TEXT,
13222- address TEXT NOT NULL,
13223- message_id TEXT NOT NULL,
13224- message BLOB NOT NULL,
13225- headers_json TEXT,
13226- timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
13227- datetime TEXT NOT NULL DEFAULT (datetime()),
13228- created INTEGER NOT NULL DEFAULT (unixepoch())
13229- );
13230-
13231- CREATE TABLE IF NOT EXISTS template (
13232- pk INTEGER PRIMARY KEY NOT NULL,
13233- name TEXT NOT NULL,
13234- list INTEGER,
13235- subject TEXT,
13236- headers_json TEXT,
13237- body TEXT NOT NULL,
13238- created INTEGER NOT NULL DEFAULT (unixepoch()),
13239- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
13240- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
13241- UNIQUE (list, name) ON CONFLICT ROLLBACK
13242- );
13243-
13244- CREATE TABLE IF NOT EXISTS settings_json_schema (
13245- pk INTEGER PRIMARY KEY NOT NULL,
13246- id TEXT NOT NULL UNIQUE,
13247- value JSON NOT NULL CHECK (json_type(value) = 'object'),
13248- created INTEGER NOT NULL DEFAULT (unixepoch()),
13249- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
13250- );
13251-
13252- CREATE TABLE IF NOT EXISTS list_settings_json (
13253- pk INTEGER PRIMARY KEY NOT NULL,
13254- name TEXT NOT NULL,
13255- list INTEGER,
13256- value JSON NOT NULL CHECK (json_type(value) = 'object'),
13257- is_valid BOOLEAN_TYPE(is_valid) DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
13258- created INTEGER NOT NULL DEFAULT (unixepoch()),
13259- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
13260- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
13261- FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
13262- UNIQUE (list, name) ON CONFLICT ROLLBACK
13263- );
13264-
13265- CREATE TRIGGER
13266- IF NOT EXISTS is_valid_settings_json_on_update
13267- AFTER UPDATE OF value, name, is_valid ON list_settings_json
13268- FOR EACH ROW
13269- BEGIN
13270- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
13271- UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
13272- END;
13273-
13274- CREATE TRIGGER
13275- IF NOT EXISTS is_valid_settings_json_on_insert
13276- AFTER INSERT ON list_settings_json
13277- FOR EACH ROW
13278- BEGIN
13279- SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
13280- UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
13281- END;
13282-
13283- CREATE TRIGGER
13284- IF NOT EXISTS invalidate_settings_json_on_schema_update
13285- AFTER UPDATE OF value, id ON settings_json_schema
13286- FOR EACH ROW
13287- BEGIN
13288- UPDATE list_settings_json SET name = NEW.id, is_valid = BOOLEAN_FALSE() WHERE name = OLD.id;
13289- END;
13290-
13291- -- # Queues
13292- --
13293- -- ## The "maildrop" queue
13294- --
13295- -- Messages that have been submitted but not yet processed, await processing
13296- -- in the "maildrop" queue. Messages can be added to the "maildrop" queue
13297- -- even when mailpot is not running.
13298- --
13299- -- ## The "deferred" queue
13300- --
13301- -- When all the deliverable recipients for a message are delivered, and for
13302- -- some recipients delivery failed for a transient reason (it might succeed
13303- -- later), the message is placed in the "deferred" queue.
13304- --
13305- -- ## The "hold" queue
13306- --
13307- -- List administrators may introduce rules for emails to be placed
13308- -- indefinitely in the "hold" queue. Messages placed in the "hold" queue stay
13309- -- there until the administrator intervenes. No periodic delivery attempts
13310- -- are made for messages in the "hold" queue.
13311-
13312- -- ## The "out" queue
13313- --
13314- -- Emails that must be sent as soon as possible.
13315- CREATE TABLE IF NOT EXISTS queue (
13316- pk INTEGER PRIMARY KEY NOT NULL,
13317- which TEXT
13318- CHECK (
13319- which IN
13320- ('maildrop',
13321- 'hold',
13322- 'deferred',
13323- 'corrupt',
13324- 'error',
13325- 'out')
13326- ) NOT NULL,
13327- list INTEGER,
13328- comment TEXT,
13329- to_addresses TEXT NOT NULL,
13330- from_address TEXT NOT NULL,
13331- subject TEXT NOT NULL,
13332- message_id TEXT NOT NULL,
13333- message BLOB NOT NULL,
13334- timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
13335- datetime TEXT NOT NULL DEFAULT (datetime()),
13336- FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
13337- UNIQUE (to_addresses, message_id) ON CONFLICT ROLLBACK
13338- );
13339-
13340- CREATE TABLE IF NOT EXISTS bounce (
13341- pk INTEGER PRIMARY KEY NOT NULL,
13342- subscription INTEGER NOT NULL UNIQUE,
13343- count INTEGER NOT NULL DEFAULT 0,
13344- last_bounce TEXT NOT NULL DEFAULT (datetime()),
13345- FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
13346- );
13347-
13348- CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
13349- CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
13350- CREATE INDEX IF NOT EXISTS list_idx ON list(id);
13351- CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
13352-
13353- -- TAG(accept_candidate): Update candidacy with 'subscription' foreign key on
13354- -- 'subscription' insert.
13355- CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
13356- FOR EACH ROW
13357- BEGIN
13358- UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
13359- WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
13360- END;
13361-
13362- -- TAG(verify_subscription_email): If list settings require e-mail to be
13363- -- verified, update new subscription's 'verify' column value.
13364- CREATE TRIGGER IF NOT EXISTS verify_subscription_email AFTER INSERT ON subscription
13365- FOR EACH ROW
13366- BEGIN
13367- UPDATE subscription
13368- SET verified = BOOLEAN_FALSE(), last_modified = unixepoch()
13369- WHERE
13370- subscription.pk = NEW.pk
13371- AND
13372- EXISTS
13373- (SELECT 1 FROM list WHERE pk = NEW.list AND verify = BOOLEAN_TRUE());
13374- END;
13375-
13376- -- TAG(add_account): Update list subscription entries with 'account' foreign
13377- -- key, if addresses match.
13378- CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
13379- FOR EACH ROW
13380- BEGIN
13381- UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
13382- WHERE subscription.address = NEW.address;
13383- END;
13384-
13385- -- TAG(add_account_to_subscription): When adding a new 'subscription', auto
13386- -- set 'account' value if there already exists an 'account' entry with the
13387- -- same address.
13388- CREATE TRIGGER IF NOT EXISTS add_account_to_subscription
13389- AFTER INSERT ON subscription
13390- FOR EACH ROW
13391- WHEN
13392- NEW.account IS NULL
13393- AND EXISTS (SELECT 1 FROM account WHERE address = NEW.address)
13394- BEGIN
13395- UPDATE subscription
13396- SET account = (SELECT pk FROM account WHERE address = NEW.address),
13397- last_modified = unixepoch()
13398- WHERE subscription.pk = NEW.pk;
13399- END;
13400-
13401- update_last_modified(`list')
13402- update_last_modified(`owner')
13403- update_last_modified(`post_policy')
13404- update_last_modified(`subscription_policy')
13405- update_last_modified(`subscription')
13406- update_last_modified(`account')
13407- update_last_modified(`candidate_subscription')
13408- update_last_modified(`template')
13409- update_last_modified(`settings_json_schema')
13410- update_last_modified(`list_settings_json')
13411-
13412- CREATE TRIGGER
13413- IF NOT EXISTS sort_topics_update_trigger
13414- AFTER UPDATE ON list
13415- FOR EACH ROW
13416- WHEN NEW.topics != OLD.topics
13417- BEGIN
13418- UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
13419- END;
13420-
13421- CREATE TRIGGER
13422- IF NOT EXISTS sort_topics_new_trigger
13423- AFTER INSERT ON list
13424- FOR EACH ROW
13425- BEGIN
13426- UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
13427- END;
13428 diff --git a/core/src/submission.rs b/core/src/submission.rs
13429deleted file mode 100644
13430index 6a3ca9a..0000000
13431--- a/core/src/submission.rs
13432+++ /dev/null
13433 @@ -1,73 +0,0 @@
13434- /*
13435- * This file is part of mailpot
13436- *
13437- * Copyright 2020 - Manos Pitsidianakis
13438- *
13439- * This program is free software: you can redistribute it and/or modify
13440- * it under the terms of the GNU Affero General Public License as
13441- * published by the Free Software Foundation, either version 3 of the
13442- * License, or (at your option) any later version.
13443- *
13444- * This program is distributed in the hope that it will be useful,
13445- * but WITHOUT ANY WARRANTY; without even the implied warranty of
13446- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13447- * GNU Affero General Public License for more details.
13448- *
13449- * You should have received a copy of the GNU Affero General Public License
13450- * along with this program. If not, see <https://www.gnu.org/licenses/>.
13451- */
13452-
13453- //! Submit e-mail through SMTP.
13454-
13455- use std::{future::Future, pin::Pin};
13456-
13457- use melib::smtp::*;
13458-
13459- use crate::{errors::*, queue::QueueEntry, Connection};
13460-
13461- type ResultFuture<T> = Result<Pin<Box<dyn Future<Output = Result<T>> + Send + 'static>>>;
13462-
13463- impl Connection {
13464- /// Return an SMTP connection handle if the database connection has one
13465- /// configured.
13466- pub fn new_smtp_connection(&self) -> ResultFuture<SmtpConnection> {
13467- if let crate::SendMail::Smtp(ref smtp_conf) = &self.conf().send_mail {
13468- let smtp_conf = smtp_conf.clone();
13469- Ok(Box::pin(async move {
13470- Ok(SmtpConnection::new_connection(smtp_conf).await?)
13471- }))
13472- } else {
13473- Err("No SMTP configuration found: use the shell command instead.".into())
13474- }
13475- }
13476-
13477- /// Submit queue items from `values` to their recipients.
13478- pub async fn submit(
13479- smtp_connection: &mut melib::smtp::SmtpConnection,
13480- message: &QueueEntry,
13481- dry_run: bool,
13482- ) -> Result<()> {
13483- let QueueEntry {
13484- ref comment,
13485- ref to_addresses,
13486- ref from_address,
13487- ref subject,
13488- ref message,
13489- ..
13490- } = message;
13491- log::info!(
13492- "Sending message from {from_address} to {to_addresses} with subject {subject:?} and \
13493- comment {comment:?}",
13494- );
13495- let recipients = melib::Address::list_try_from(to_addresses)
13496- .context(format!("Could not parse {to_addresses:?}"))?;
13497- if dry_run {
13498- log::warn!("Dry run is true, not actually submitting anything to SMTP server.");
13499- } else {
13500- smtp_connection
13501- .mail_transaction(&String::from_utf8_lossy(message), Some(&recipients))
13502- .await?;
13503- }
13504- Ok(())
13505- }
13506- }
13507 diff --git a/core/src/subscriptions.rs b/core/src/subscriptions.rs
13508deleted file mode 100644
13509index cb6edbf..0000000
13510--- a/core/src/subscriptions.rs
13511+++ /dev/null
13512 @@ -1,815 +0,0 @@
13513- /*
13514- * This file is part of mailpot
13515- *
13516- * Copyright 2020 - Manos Pitsidianakis
13517- *
13518- * This program is free software: you can redistribute it and/or modify
13519- * it under the terms of the GNU Affero General Public License as
13520- * published by the Free Software Foundation, either version 3 of the
13521- * License, or (at your option) any later version.
13522- *
13523- * This program is distributed in the hope that it will be useful,
13524- * but WITHOUT ANY WARRANTY; without even the implied warranty of
13525- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13526- * GNU Affero General Public License for more details.
13527- *
13528- * You should have received a copy of the GNU Affero General Public License
13529- * along with this program. If not, see <https://www.gnu.org/licenses/>.
13530- */
13531-
13532- //! User subscriptions.
13533-
13534- use log::trace;
13535- use rusqlite::OptionalExtension;
13536-
13537- use crate::{
13538- errors::{ErrorKind::*, *},
13539- models::{
13540- changesets::{AccountChangeset, ListSubscriptionChangeset},
13541- Account, ListCandidateSubscription, ListSubscription,
13542- },
13543- Connection, DbVal,
13544- };
13545-
13546- impl Connection {
13547- /// Fetch all subscriptions of a mailing list.
13548- pub fn list_subscriptions(&self, list_pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
13549- let mut stmt = self
13550- .connection
13551- .prepare("SELECT * FROM subscription WHERE list = ?;")?;
13552- let list_iter = stmt.query_map([&list_pk], |row| {
13553- let pk = row.get("pk")?;
13554- Ok(DbVal(
13555- ListSubscription {
13556- pk: row.get("pk")?,
13557- list: row.get("list")?,
13558- address: row.get("address")?,
13559- account: row.get("account")?,
13560- name: row.get("name")?,
13561- digest: row.get("digest")?,
13562- enabled: row.get("enabled")?,
13563- verified: row.get("verified")?,
13564- hide_address: row.get("hide_address")?,
13565- receive_duplicates: row.get("receive_duplicates")?,
13566- receive_own_posts: row.get("receive_own_posts")?,
13567- receive_confirmation: row.get("receive_confirmation")?,
13568- },
13569- pk,
13570- ))
13571- })?;
13572-
13573- let mut ret = vec![];
13574- for list in list_iter {
13575- let list = list?;
13576- ret.push(list);
13577- }
13578- Ok(ret)
13579- }
13580-
13581- /// Fetch mailing list subscription.
13582- pub fn list_subscription(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListSubscription>> {
13583- let mut stmt = self
13584- .connection
13585- .prepare("SELECT * FROM subscription WHERE list = ? AND pk = ?;")?;
13586-
13587- let ret = stmt.query_row([&list_pk, &pk], |row| {
13588- let _pk: i64 = row.get("pk")?;
13589- debug_assert_eq!(pk, _pk);
13590- Ok(DbVal(
13591- ListSubscription {
13592- pk,
13593- list: row.get("list")?,
13594- address: row.get("address")?,
13595- account: row.get("account")?,
13596- name: row.get("name")?,
13597- digest: row.get("digest")?,
13598- enabled: row.get("enabled")?,
13599- verified: row.get("verified")?,
13600- hide_address: row.get("hide_address")?,
13601- receive_duplicates: row.get("receive_duplicates")?,
13602- receive_own_posts: row.get("receive_own_posts")?,
13603- receive_confirmation: row.get("receive_confirmation")?,
13604- },
13605- pk,
13606- ))
13607- })?;
13608- Ok(ret)
13609- }
13610-
13611- /// Fetch mailing list subscription by their address.
13612- pub fn list_subscription_by_address(
13613- &self,
13614- list_pk: i64,
13615- address: &str,
13616- ) -> Result<DbVal<ListSubscription>> {
13617- let mut stmt = self
13618- .connection
13619- .prepare("SELECT * FROM subscription WHERE list = ? AND address = ?;")?;
13620-
13621- let ret = stmt.query_row(rusqlite::params![&list_pk, &address], |row| {
13622- let pk = row.get("pk")?;
13623- let address_ = row.get("address")?;
13624- debug_assert_eq!(address, &address_);
13625- Ok(DbVal(
13626- ListSubscription {
13627- pk,
13628- list: row.get("list")?,
13629- address: address_,
13630- account: row.get("account")?,
13631- name: row.get("name")?,
13632- digest: row.get("digest")?,
13633- enabled: row.get("enabled")?,
13634- verified: row.get("verified")?,
13635- hide_address: row.get("hide_address")?,
13636- receive_duplicates: row.get("receive_duplicates")?,
13637- receive_own_posts: row.get("receive_own_posts")?,
13638- receive_confirmation: row.get("receive_confirmation")?,
13639- },
13640- pk,
13641- ))
13642- })?;
13643- Ok(ret)
13644- }
13645-
13646- /// Add subscription to mailing list.
13647- pub fn add_subscription(
13648- &self,
13649- list_pk: i64,
13650- mut new_val: ListSubscription,
13651- ) -> Result<DbVal<ListSubscription>> {
13652- new_val.list = list_pk;
13653- let mut stmt = self
13654- .connection
13655- .prepare(
13656- "INSERT INTO subscription(list, address, account, name, enabled, digest, \
13657- verified, hide_address, receive_duplicates, receive_own_posts, \
13658- receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;",
13659- )
13660- .unwrap();
13661- let val = stmt.query_row(
13662- rusqlite::params![
13663- &new_val.list,
13664- &new_val.address,
13665- &new_val.account,
13666- &new_val.name,
13667- &new_val.enabled,
13668- &new_val.digest,
13669- &new_val.verified,
13670- &new_val.hide_address,
13671- &new_val.receive_duplicates,
13672- &new_val.receive_own_posts,
13673- &new_val.receive_confirmation
13674- ],
13675- |row| {
13676- let pk = row.get("pk")?;
13677- Ok(DbVal(
13678- ListSubscription {
13679- pk,
13680- list: row.get("list")?,
13681- address: row.get("address")?,
13682- name: row.get("name")?,
13683- account: row.get("account")?,
13684- digest: row.get("digest")?,
13685- enabled: row.get("enabled")?,
13686- verified: row.get("verified")?,
13687- hide_address: row.get("hide_address")?,
13688- receive_duplicates: row.get("receive_duplicates")?,
13689- receive_own_posts: row.get("receive_own_posts")?,
13690- receive_confirmation: row.get("receive_confirmation")?,
13691- },
13692- pk,
13693- ))
13694- },
13695- )?;
13696- trace!("add_subscription {:?}.", &val);
13697- // table entry might be modified by triggers, so don't rely on RETURNING value.
13698- self.list_subscription(list_pk, val.pk())
13699- }
13700-
13701- /// Fetch all candidate subscriptions of a mailing list.
13702- pub fn list_subscription_requests(
13703- &self,
13704- list_pk: i64,
13705- ) -> Result<Vec<DbVal<ListCandidateSubscription>>> {
13706- let mut stmt = self
13707- .connection
13708- .prepare("SELECT * FROM candidate_subscription WHERE list = ?;")?;
13709- let list_iter = stmt.query_map([&list_pk], |row| {
13710- let pk = row.get("pk")?;
13711- Ok(DbVal(
13712- ListCandidateSubscription {
13713- pk: row.get("pk")?,
13714- list: row.get("list")?,
13715- address: row.get("address")?,
13716- name: row.get("name")?,
13717- accepted: row.get("accepted")?,
13718- },
13719- pk,
13720- ))
13721- })?;
13722-
13723- let mut ret = vec![];
13724- for list in list_iter {
13725- let list = list?;
13726- ret.push(list);
13727- }
13728- Ok(ret)
13729- }
13730-
13731- /// Create subscription candidate.
13732- pub fn add_candidate_subscription(
13733- &self,
13734- list_pk: i64,
13735- mut new_val: ListSubscription,
13736- ) -> Result<DbVal<ListCandidateSubscription>> {
13737- new_val.list = list_pk;
13738- let mut stmt = self.connection.prepare(
13739- "INSERT INTO candidate_subscription(list, address, name, accepted) VALUES(?, ?, ?, ?) \
13740- RETURNING *;",
13741- )?;
13742- let val = stmt.query_row(
13743- rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
13744- |row| {
13745- let pk = row.get("pk")?;
13746- Ok(DbVal(
13747- ListCandidateSubscription {
13748- pk,
13749- list: row.get("list")?,
13750- address: row.get("address")?,
13751- name: row.get("name")?,
13752- accepted: row.get("accepted")?,
13753- },
13754- pk,
13755- ))
13756- },
13757- )?;
13758- drop(stmt);
13759-
13760- trace!("add_candidate_subscription {:?}.", &val);
13761- // table entry might be modified by triggers, so don't rely on RETURNING value.
13762- self.candidate_subscription(val.pk())
13763- }
13764-
13765- /// Fetch subscription candidate by primary key.
13766- pub fn candidate_subscription(&self, pk: i64) -> Result<DbVal<ListCandidateSubscription>> {
13767- let mut stmt = self
13768- .connection
13769- .prepare("SELECT * FROM candidate_subscription WHERE pk = ?;")?;
13770- let val = stmt
13771- .query_row(rusqlite::params![&pk], |row| {
13772- let _pk: i64 = row.get("pk")?;
13773- debug_assert_eq!(pk, _pk);
13774- Ok(DbVal(
13775- ListCandidateSubscription {
13776- pk,
13777- list: row.get("list")?,
13778- address: row.get("address")?,
13779- name: row.get("name")?,
13780- accepted: row.get("accepted")?,
13781- },
13782- pk,
13783- ))
13784- })
13785- .map_err(|err| {
13786- if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
13787- Error::from(err)
13788- .chain_err(|| NotFound("Candidate subscription with this pk not found!"))
13789- } else {
13790- err.into()
13791- }
13792- })?;
13793-
13794- Ok(val)
13795- }
13796-
13797- /// Accept subscription candidate.
13798- pub fn accept_candidate_subscription(&self, pk: i64) -> Result<DbVal<ListSubscription>> {
13799- let val = self.connection.query_row(
13800- "INSERT INTO subscription(list, address, name, enabled, digest, verified, \
13801- hide_address, receive_duplicates, receive_own_posts, receive_confirmation) SELECT \
13802- list, address, name, 1, 0, 0, 0, 1, 1, 0 FROM candidate_subscription WHERE pk = ? \
13803- RETURNING *;",
13804- rusqlite::params![&pk],
13805- |row| {
13806- let pk = row.get("pk")?;
13807- Ok(DbVal(
13808- ListSubscription {
13809- pk,
13810- list: row.get("list")?,
13811- address: row.get("address")?,
13812- account: row.get("account")?,
13813- name: row.get("name")?,
13814- digest: row.get("digest")?,
13815- enabled: row.get("enabled")?,
13816- verified: row.get("verified")?,
13817- hide_address: row.get("hide_address")?,
13818- receive_duplicates: row.get("receive_duplicates")?,
13819- receive_own_posts: row.get("receive_own_posts")?,
13820- receive_confirmation: row.get("receive_confirmation")?,
13821- },
13822- pk,
13823- ))
13824- },
13825- )?;
13826-
13827- trace!("accept_candidate_subscription {:?}.", &val);
13828- // table entry might be modified by triggers, so don't rely on RETURNING value.
13829- let ret = self.list_subscription(val.list, val.pk())?;
13830-
13831- // assert that [ref:accept_candidate] trigger works.
13832- debug_assert_eq!(Some(ret.pk), self.candidate_subscription(pk)?.accepted);
13833- Ok(ret)
13834- }
13835-
13836- /// Remove a subscription by their address.
13837- pub fn remove_subscription(&self, list_pk: i64, address: &str) -> Result<()> {
13838- self.connection
13839- .query_row(
13840- "DELETE FROM subscription WHERE list = ? AND address = ? RETURNING *;",
13841- rusqlite::params![&list_pk, &address],
13842- |_| Ok(()),
13843- )
13844- .map_err(|err| {
13845- if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
13846- Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
13847- } else {
13848- err.into()
13849- }
13850- })?;
13851-
13852- Ok(())
13853- }
13854-
13855- /// Update a mailing list subscription.
13856- pub fn update_subscription(&self, change_set: ListSubscriptionChangeset) -> Result<()> {
13857- let pk = self
13858- .list_subscription_by_address(change_set.list, &change_set.address)?
13859- .pk;
13860- if matches!(
13861- change_set,
13862- ListSubscriptionChangeset {
13863- list: _,
13864- address: _,
13865- account: None,
13866- name: None,
13867- digest: None,
13868- verified: None,
13869- hide_address: None,
13870- receive_duplicates: None,
13871- receive_own_posts: None,
13872- receive_confirmation: None,
13873- enabled: None,
13874- }
13875- ) {
13876- return Ok(());
13877- }
13878-
13879- let ListSubscriptionChangeset {
13880- list,
13881- address: _,
13882- name,
13883- account,
13884- digest,
13885- enabled,
13886- verified,
13887- hide_address,
13888- receive_duplicates,
13889- receive_own_posts,
13890- receive_confirmation,
13891- } = change_set;
13892- let tx = self.savepoint(Some(stringify!(update_subscription)))?;
13893-
13894- macro_rules! update {
13895- ($field:tt) => {{
13896- if let Some($field) = $field {
13897- tx.connection.execute(
13898- concat!(
13899- "UPDATE subscription SET ",
13900- stringify!($field),
13901- " = ? WHERE list = ? AND pk = ?;"
13902- ),
13903- rusqlite::params![&$field, &list, &pk],
13904- )?;
13905- }
13906- }};
13907- }
13908- update!(name);
13909- update!(account);
13910- update!(digest);
13911- update!(enabled);
13912- update!(verified);
13913- update!(hide_address);
13914- update!(receive_duplicates);
13915- update!(receive_own_posts);
13916- update!(receive_confirmation);
13917-
13918- tx.commit()?;
13919- Ok(())
13920- }
13921-
13922- /// Fetch account by pk.
13923- pub fn account(&self, pk: i64) -> Result<Option<DbVal<Account>>> {
13924- let mut stmt = self
13925- .connection
13926- .prepare("SELECT * FROM account WHERE pk = ?;")?;
13927-
13928- let ret = stmt
13929- .query_row(rusqlite::params![&pk], |row| {
13930- let _pk: i64 = row.get("pk")?;
13931- debug_assert_eq!(pk, _pk);
13932- Ok(DbVal(
13933- Account {
13934- pk,
13935- name: row.get("name")?,
13936- address: row.get("address")?,
13937- public_key: row.get("public_key")?,
13938- password: row.get("password")?,
13939- enabled: row.get("enabled")?,
13940- },
13941- pk,
13942- ))
13943- })
13944- .optional()?;
13945- Ok(ret)
13946- }
13947-
13948- /// Fetch account by address.
13949- pub fn account_by_address(&self, address: &str) -> Result<Option<DbVal<Account>>> {
13950- let mut stmt = self
13951- .connection
13952- .prepare("SELECT * FROM account WHERE address = ?;")?;
13953-
13954- let ret = stmt
13955- .query_row(rusqlite::params![&address], |row| {
13956- let pk = row.get("pk")?;
13957- Ok(DbVal(
13958- Account {
13959- pk,
13960- name: row.get("name")?,
13961- address: row.get("address")?,
13962- public_key: row.get("public_key")?,
13963- password: row.get("password")?,
13964- enabled: row.get("enabled")?,
13965- },
13966- pk,
13967- ))
13968- })
13969- .optional()?;
13970- Ok(ret)
13971- }
13972-
13973- /// Fetch all subscriptions of an account by primary key.
13974- pub fn account_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
13975- let mut stmt = self
13976- .connection
13977- .prepare("SELECT * FROM subscription WHERE account = ?;")?;
13978- let list_iter = stmt.query_map([&pk], |row| {
13979- let pk = row.get("pk")?;
13980- Ok(DbVal(
13981- ListSubscription {
13982- pk: row.get("pk")?,
13983- list: row.get("list")?,
13984- address: row.get("address")?,
13985- account: row.get("account")?,
13986- name: row.get("name")?,
13987- digest: row.get("digest")?,
13988- enabled: row.get("enabled")?,
13989- verified: row.get("verified")?,
13990- hide_address: row.get("hide_address")?,
13991- receive_duplicates: row.get("receive_duplicates")?,
13992- receive_own_posts: row.get("receive_own_posts")?,
13993- receive_confirmation: row.get("receive_confirmation")?,
13994- },
13995- pk,
13996- ))
13997- })?;
13998-
13999- let mut ret = vec![];
14000- for list in list_iter {
14001- let list = list?;
14002- ret.push(list);
14003- }
14004- Ok(ret)
14005- }
14006-
14007- /// Fetch all accounts.
14008- pub fn accounts(&self) -> Result<Vec<DbVal<Account>>> {
14009- let mut stmt = self
14010- .connection
14011- .prepare("SELECT * FROM account ORDER BY pk ASC;")?;
14012- let list_iter = stmt.query_map([], |row| {
14013- let pk = row.get("pk")?;
14014- Ok(DbVal(
14015- Account {
14016- pk,
14017- name: row.get("name")?,
14018- address: row.get("address")?,
14019- public_key: row.get("public_key")?,
14020- password: row.get("password")?,
14021- enabled: row.get("enabled")?,
14022- },
14023- pk,
14024- ))
14025- })?;
14026-
14027- let mut ret = vec![];
14028- for list in list_iter {
14029- let list = list?;
14030- ret.push(list);
14031- }
14032- Ok(ret)
14033- }
14034-
14035- /// Add account.
14036- pub fn add_account(&self, new_val: Account) -> Result<DbVal<Account>> {
14037- let mut stmt = self
14038- .connection
14039- .prepare(
14040- "INSERT INTO account(name, address, public_key, password, enabled) VALUES(?, ?, \
14041- ?, ?, ?) RETURNING *;",
14042- )
14043- .unwrap();
14044- let ret = stmt.query_row(
14045- rusqlite::params![
14046- &new_val.name,
14047- &new_val.address,
14048- &new_val.public_key,
14049- &new_val.password,
14050- &new_val.enabled,
14051- ],
14052- |row| {
14053- let pk = row.get("pk")?;
14054- Ok(DbVal(
14055- Account {
14056- pk,
14057- name: row.get("name")?,
14058- address: row.get("address")?,
14059- public_key: row.get("public_key")?,
14060- password: row.get("password")?,
14061- enabled: row.get("enabled")?,
14062- },
14063- pk,
14064- ))
14065- },
14066- )?;
14067-
14068- trace!("add_account {:?}.", &ret);
14069- Ok(ret)
14070- }
14071-
14072- /// Remove an account by their address.
14073- pub fn remove_account(&self, address: &str) -> Result<()> {
14074- self.connection
14075- .query_row(
14076- "DELETE FROM account WHERE address = ? RETURNING *;",
14077- rusqlite::params![&address],
14078- |_| Ok(()),
14079- )
14080- .map_err(|err| {
14081- if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
14082- Error::from(err).chain_err(|| NotFound("account not found!"))
14083- } else {
14084- err.into()
14085- }
14086- })?;
14087-
14088- Ok(())
14089- }
14090-
14091- /// Update an account.
14092- pub fn update_account(&self, change_set: AccountChangeset) -> Result<()> {
14093- let Some(acc) = self.account_by_address(&change_set.address)? else {
14094- return Err(NotFound("account with this address not found!").into());
14095- };
14096- let pk = acc.pk;
14097- if matches!(
14098- change_set,
14099- AccountChangeset {
14100- address: _,
14101- name: None,
14102- public_key: None,
14103- password: None,
14104- enabled: None,
14105- }
14106- ) {
14107- return Ok(());
14108- }
14109-
14110- let AccountChangeset {
14111- address: _,
14112- name,
14113- public_key,
14114- password,
14115- enabled,
14116- } = change_set;
14117- let tx = self.savepoint(Some(stringify!(update_account)))?;
14118-
14119- macro_rules! update {
14120- ($field:tt) => {{
14121- if let Some($field) = $field {
14122- tx.connection.execute(
14123- concat!(
14124- "UPDATE account SET ",
14125- stringify!($field),
14126- " = ? WHERE pk = ?;"
14127- ),
14128- rusqlite::params![&$field, &pk],
14129- )?;
14130- }
14131- }};
14132- }
14133- update!(name);
14134- update!(public_key);
14135- update!(password);
14136- update!(enabled);
14137-
14138- tx.commit()?;
14139- Ok(())
14140- }
14141- }
14142-
14143- #[cfg(test)]
14144- mod tests {
14145- use super::*;
14146- use crate::*;
14147-
14148- #[test]
14149- fn test_subscription_ops() {
14150- use tempfile::TempDir;
14151-
14152- let tmp_dir = TempDir::new().unwrap();
14153- let db_path = tmp_dir.path().join("mpot.db");
14154- let config = Configuration {
14155- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
14156- db_path,
14157- data_path: tmp_dir.path().to_path_buf(),
14158- administrators: vec![],
14159- };
14160-
14161- let db = Connection::open_or_create_db(config).unwrap().trusted();
14162- let list = db
14163- .create_list(MailingList {
14164- pk: -1,
14165- name: "foobar chat".into(),
14166- id: "foo-chat".into(),
14167- address: "foo-chat@example.com".into(),
14168- topics: vec![],
14169- description: None,
14170- archive_url: None,
14171- })
14172- .unwrap();
14173- let secondary_list = db
14174- .create_list(MailingList {
14175- pk: -1,
14176- name: "foobar chat2".into(),
14177- id: "foo-chat2".into(),
14178- address: "foo-chat2@example.com".into(),
14179- topics: vec![],
14180- description: None,
14181- archive_url: None,
14182- })
14183- .unwrap();
14184- for i in 0..4 {
14185- let sub = db
14186- .add_subscription(
14187- list.pk(),
14188- ListSubscription {
14189- pk: -1,
14190- list: list.pk(),
14191- address: format!("{i}@example.com"),
14192- account: None,
14193- name: Some(format!("User{i}")),
14194- digest: false,
14195- hide_address: false,
14196- receive_duplicates: false,
14197- receive_own_posts: false,
14198- receive_confirmation: false,
14199- enabled: true,
14200- verified: false,
14201- },
14202- )
14203- .unwrap();
14204- assert_eq!(db.list_subscription(list.pk(), sub.pk()).unwrap(), sub);
14205- assert_eq!(
14206- db.list_subscription_by_address(list.pk(), &sub.address)
14207- .unwrap(),
14208- sub
14209- );
14210- }
14211-
14212- assert_eq!(db.accounts().unwrap(), vec![]);
14213- assert_eq!(
14214- db.remove_subscription(list.pk(), "nonexistent@example.com")
14215- .map_err(|err| err.to_string())
14216- .unwrap_err(),
14217- NotFound("list or list owner not found!").to_string()
14218- );
14219-
14220- let cand = db
14221- .add_candidate_subscription(
14222- list.pk(),
14223- ListSubscription {
14224- pk: -1,
14225- list: list.pk(),
14226- address: "4@example.com".into(),
14227- account: None,
14228- name: Some("User4".into()),
14229- digest: false,
14230- hide_address: false,
14231- receive_duplicates: false,
14232- receive_own_posts: false,
14233- receive_confirmation: false,
14234- enabled: true,
14235- verified: false,
14236- },
14237- )
14238- .unwrap();
14239- let accepted = db.accept_candidate_subscription(cand.pk()).unwrap();
14240-
14241- assert_eq!(db.account(5).unwrap(), None);
14242- assert_eq!(
14243- db.remove_account("4@example.com")
14244- .map_err(|err| err.to_string())
14245- .unwrap_err(),
14246- NotFound("account not found!").to_string()
14247- );
14248-
14249- let acc = db
14250- .add_account(Account {
14251- pk: -1,
14252- name: accepted.name.clone(),
14253- address: accepted.address.clone(),
14254- public_key: None,
14255- password: String::new(),
14256- enabled: true,
14257- })
14258- .unwrap();
14259-
14260- // Test [ref:add_account] SQL trigger (see schema.sql)
14261- assert_eq!(
14262- db.list_subscription(list.pk(), accepted.pk())
14263- .unwrap()
14264- .account,
14265- Some(acc.pk())
14266- );
14267- // Test [ref:add_account_to_subscription] SQL trigger (see schema.sql)
14268- let sub = db
14269- .add_subscription(
14270- secondary_list.pk(),
14271- ListSubscription {
14272- pk: -1,
14273- list: secondary_list.pk(),
14274- address: "4@example.com".into(),
14275- account: None,
14276- name: Some("User4".into()),
14277- digest: false,
14278- hide_address: false,
14279- receive_duplicates: false,
14280- receive_own_posts: false,
14281- receive_confirmation: false,
14282- enabled: true,
14283- verified: true,
14284- },
14285- )
14286- .unwrap();
14287- assert_eq!(sub.account, Some(acc.pk()));
14288- // Test [ref:verify_subscription_email] SQL trigger (see schema.sql)
14289- assert!(!sub.verified);
14290-
14291- assert_eq!(db.accounts().unwrap(), vec![acc.clone()]);
14292-
14293- assert_eq!(
14294- db.update_account(AccountChangeset {
14295- address: "nonexistent@example.com".into(),
14296- ..AccountChangeset::default()
14297- })
14298- .map_err(|err| err.to_string())
14299- .unwrap_err(),
14300- NotFound("account with this address not found!").to_string()
14301- );
14302- assert_eq!(
14303- db.update_account(AccountChangeset {
14304- address: acc.address.clone(),
14305- ..AccountChangeset::default()
14306- })
14307- .map_err(|err| err.to_string()),
14308- Ok(())
14309- );
14310- assert_eq!(
14311- db.update_account(AccountChangeset {
14312- address: acc.address.clone(),
14313- enabled: Some(Some(false)),
14314- ..AccountChangeset::default()
14315- })
14316- .map_err(|err| err.to_string()),
14317- Ok(())
14318- );
14319- assert!(!db.account(acc.pk()).unwrap().unwrap().enabled);
14320- assert_eq!(
14321- db.remove_account("4@example.com")
14322- .map_err(|err| err.to_string()),
14323- Ok(())
14324- );
14325- assert_eq!(db.accounts().unwrap(), vec![]);
14326- }
14327- }
14328 diff --git a/core/src/templates.rs b/core/src/templates.rs
14329deleted file mode 100644
14330index 3f1fb66..0000000
14331--- a/core/src/templates.rs
14332+++ /dev/null
14333 @@ -1,370 +0,0 @@
14334- /*
14335- * This file is part of mailpot
14336- *
14337- * Copyright 2020 - Manos Pitsidianakis
14338- *
14339- * This program is free software: you can redistribute it and/or modify
14340- * it under the terms of the GNU Affero General Public License as
14341- * published by the Free Software Foundation, either version 3 of the
14342- * License, or (at your option) any later version.
14343- *
14344- * This program is distributed in the hope that it will be useful,
14345- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14346- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14347- * GNU Affero General Public License for more details.
14348- *
14349- * You should have received a copy of the GNU Affero General Public License
14350- * along with this program. If not, see <https://www.gnu.org/licenses/>.
14351- */
14352-
14353- //! Named templates, for generated e-mail like confirmations, alerts etc.
14354- //!
14355- //! Template database model: [`Template`].
14356-
14357- use log::trace;
14358- use rusqlite::OptionalExtension;
14359-
14360- use crate::{
14361- errors::{ErrorKind::*, *},
14362- Connection, DbVal,
14363- };
14364-
14365- /// A named template.
14366- #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
14367- pub struct Template {
14368- /// Database primary key.
14369- pub pk: i64,
14370- /// Name.
14371- pub name: String,
14372- /// Associated list foreign key, optional.
14373- pub list: Option<i64>,
14374- /// Subject template.
14375- pub subject: Option<String>,
14376- /// Extra headers template.
14377- pub headers_json: Option<serde_json::Value>,
14378- /// Body template.
14379- pub body: String,
14380- }
14381-
14382- impl std::fmt::Display for Template {
14383- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
14384- write!(fmt, "{:?}", self)
14385- }
14386- }
14387-
14388- impl Template {
14389- /// Template name for generic list help e-mail.
14390- pub const GENERIC_HELP: &'static str = "generic-help";
14391- /// Template name for generic failure e-mail.
14392- pub const GENERIC_FAILURE: &'static str = "generic-failure";
14393- /// Template name for generic success e-mail.
14394- pub const GENERIC_SUCCESS: &'static str = "generic-success";
14395- /// Template name for subscription confirmation e-mail.
14396- pub const SUBSCRIPTION_CONFIRMATION: &'static str = "subscription-confirmation";
14397- /// Template name for unsubscription confirmation e-mail.
14398- pub const UNSUBSCRIPTION_CONFIRMATION: &'static str = "unsubscription-confirmation";
14399- /// Template name for subscription request notice e-mail (for list owners).
14400- pub const SUBSCRIPTION_REQUEST_NOTICE_OWNER: &'static str = "subscription-notice-owner";
14401- /// Template name for subscription request acceptance e-mail (for the
14402- /// candidates).
14403- pub const SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT: &'static str =
14404- "subscription-notice-candidate-accept";
14405- /// Template name for admin notices.
14406- pub const ADMIN_NOTICE: &'static str = "admin-notice";
14407-
14408- /// Render a message body from a saved named template.
14409- pub fn render(&self, context: minijinja::value::Value) -> Result<melib::Draft> {
14410- use melib::{Draft, HeaderName};
14411-
14412- let env = minijinja::Environment::new();
14413- let mut draft: Draft = Draft {
14414- body: env.render_named_str("body", &self.body, &context)?,
14415- ..Draft::default()
14416- };
14417- if let Some(ref subject) = self.subject {
14418- draft.headers.insert(
14419- HeaderName::SUBJECT,
14420- env.render_named_str("subject", subject, &context)?,
14421- );
14422- }
14423-
14424- Ok(draft)
14425- }
14426-
14427- /// Template name for generic failure e-mail.
14428- pub fn default_generic_failure() -> Self {
14429- Self {
14430- pk: -1,
14431- name: Self::GENERIC_FAILURE.to_string(),
14432- list: None,
14433- subject: Some(
14434- "{{ subject if subject else \"Your e-mail was not processed successfully.\" }}"
14435- .to_string(),
14436- ),
14437- headers_json: None,
14438- body: "{{ details|safe if details else \"The list owners and administrators have been \
14439- notified.\" }}"
14440- .to_string(),
14441- }
14442- }
14443-
14444- /// Create a plain template for generic success e-mails.
14445- pub fn default_generic_success() -> Self {
14446- Self {
14447- pk: -1,
14448- name: Self::GENERIC_SUCCESS.to_string(),
14449- list: None,
14450- subject: Some(
14451- "{{ subject if subject else \"Your e-mail was processed successfully.\" }}"
14452- .to_string(),
14453- ),
14454- headers_json: None,
14455- body: "{{ details|safe if details else \"\" }}".to_string(),
14456- }
14457- }
14458-
14459- /// Create a plain template for subscription confirmation.
14460- pub fn default_subscription_confirmation() -> Self {
14461- Self {
14462- pk: -1,
14463- name: Self::SUBSCRIPTION_CONFIRMATION.to_string(),
14464- list: None,
14465- subject: Some(
14466- "{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
14467- %}You have successfully subscribed to {{ list.name if list.name else list.id \
14468- }}{% else %}You have successfully subscribed to this list{% endif %}."
14469- .to_string(),
14470- ),
14471- headers_json: None,
14472- body: "{{ details|safe if details else \"\" }}".to_string(),
14473- }
14474- }
14475-
14476- /// Create a plain template for unsubscription confirmations.
14477- pub fn default_unsubscription_confirmation() -> Self {
14478- Self {
14479- pk: -1,
14480- name: Self::UNSUBSCRIPTION_CONFIRMATION.to_string(),
14481- list: None,
14482- subject: Some(
14483- "{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
14484- %}You have successfully unsubscribed from {{ list.name if list.name else list.id \
14485- }}{% else %}You have successfully unsubscribed from this list{% endif %}."
14486- .to_string(),
14487- ),
14488- headers_json: None,
14489- body: "{{ details|safe if details else \"\" }}".to_string(),
14490- }
14491- }
14492-
14493- /// Create a plain template for admin notices.
14494- pub fn default_admin_notice() -> Self {
14495- Self {
14496- pk: -1,
14497- name: Self::ADMIN_NOTICE.to_string(),
14498- list: None,
14499- subject: Some(
14500- "{% if list %}An error occured with list {{ list.id }}{% else %}An error \
14501- occured{% endif %}"
14502- .to_string(),
14503- ),
14504- headers_json: None,
14505- body: "{{ details|safe if details else \"\" }}".to_string(),
14506- }
14507- }
14508-
14509- /// Create a plain template for subscription requests for list owners.
14510- pub fn default_subscription_request_owner() -> Self {
14511- Self {
14512- pk: -1,
14513- name: Self::SUBSCRIPTION_REQUEST_NOTICE_OWNER.to_string(),
14514- list: None,
14515- subject: Some("Subscription request for {{ list.id }}".to_string()),
14516- headers_json: None,
14517- body: "Candidate {{ candidate.name if candidate.name else \"\" }} <{{ \
14518- candidate.address }}> Primary key: {{ candidate.pk }}\n\n{{ details|safe if \
14519- details else \"\" }}"
14520- .to_string(),
14521- }
14522- }
14523-
14524- /// Create a plain template for subscription requests for candidates.
14525- pub fn default_subscription_request_candidate_accept() -> Self {
14526- Self {
14527- pk: -1,
14528- name: Self::SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT.to_string(),
14529- list: None,
14530- subject: Some("Your subscription to {{ list.id }} is now active.".to_string()),
14531- headers_json: None,
14532- body: "{{ details|safe if details else \"\" }}".to_string(),
14533- }
14534- }
14535-
14536- /// Create a plain template for generic list help replies.
14537- pub fn default_generic_help() -> Self {
14538- Self {
14539- pk: -1,
14540- name: Self::GENERIC_HELP.to_string(),
14541- list: None,
14542- subject: Some("{{ subject if subject else \"Help for mailing list\" }}".to_string()),
14543- headers_json: None,
14544- body: "{{ details }}".to_string(),
14545- }
14546- }
14547- }
14548-
14549- impl Connection {
14550- /// Fetch all.
14551- pub fn fetch_templates(&self) -> Result<Vec<DbVal<Template>>> {
14552- let mut stmt = self
14553- .connection
14554- .prepare("SELECT * FROM template ORDER BY pk;")?;
14555- let iter = stmt.query_map(rusqlite::params![], |row| {
14556- let pk = row.get("pk")?;
14557- Ok(DbVal(
14558- Template {
14559- pk,
14560- name: row.get("name")?,
14561- list: row.get("list")?,
14562- subject: row.get("subject")?,
14563- headers_json: row.get("headers_json")?,
14564- body: row.get("body")?,
14565- },
14566- pk,
14567- ))
14568- })?;
14569-
14570- let mut ret = vec![];
14571- for templ in iter {
14572- let templ = templ?;
14573- ret.push(templ);
14574- }
14575- Ok(ret)
14576- }
14577-
14578- /// Fetch a named template.
14579- pub fn fetch_template(
14580- &self,
14581- template: &str,
14582- list_pk: Option<i64>,
14583- ) -> Result<Option<DbVal<Template>>> {
14584- let mut stmt = self
14585- .connection
14586- .prepare("SELECT * FROM template WHERE name = ? AND list IS ?;")?;
14587- let ret = stmt
14588- .query_row(rusqlite::params![&template, &list_pk], |row| {
14589- let pk = row.get("pk")?;
14590- Ok(DbVal(
14591- Template {
14592- pk,
14593- name: row.get("name")?,
14594- list: row.get("list")?,
14595- subject: row.get("subject")?,
14596- headers_json: row.get("headers_json")?,
14597- body: row.get("body")?,
14598- },
14599- pk,
14600- ))
14601- })
14602- .optional()?;
14603- if ret.is_none() && list_pk.is_some() {
14604- let mut stmt = self
14605- .connection
14606- .prepare("SELECT * FROM template WHERE name = ? AND list IS NULL;")?;
14607- Ok(stmt
14608- .query_row(rusqlite::params![&template], |row| {
14609- let pk = row.get("pk")?;
14610- Ok(DbVal(
14611- Template {
14612- pk,
14613- name: row.get("name")?,
14614- list: row.get("list")?,
14615- subject: row.get("subject")?,
14616- headers_json: row.get("headers_json")?,
14617- body: row.get("body")?,
14618- },
14619- pk,
14620- ))
14621- })
14622- .optional()?)
14623- } else {
14624- Ok(ret)
14625- }
14626- }
14627-
14628- /// Insert a named template.
14629- pub fn add_template(&self, template: Template) -> Result<DbVal<Template>> {
14630- let mut stmt = self.connection.prepare(
14631- "INSERT INTO template(name, list, subject, headers_json, body) VALUES(?, ?, ?, ?, ?) \
14632- RETURNING *;",
14633- )?;
14634- let ret = stmt
14635- .query_row(
14636- rusqlite::params![
14637- &template.name,
14638- &template.list,
14639- &template.subject,
14640- &template.headers_json,
14641- &template.body
14642- ],
14643- |row| {
14644- let pk = row.get("pk")?;
14645- Ok(DbVal(
14646- Template {
14647- pk,
14648- name: row.get("name")?,
14649- list: row.get("list")?,
14650- subject: row.get("subject")?,
14651- headers_json: row.get("headers_json")?,
14652- body: row.get("body")?,
14653- },
14654- pk,
14655- ))
14656- },
14657- )
14658- .map_err(|err| {
14659- if matches!(
14660- err,
14661- rusqlite::Error::SqliteFailure(
14662- rusqlite::ffi::Error {
14663- code: rusqlite::ffi::ErrorCode::ConstraintViolation,
14664- extended_code: 787
14665- },
14666- _
14667- )
14668- ) {
14669- Error::from(err).chain_err(|| NotFound("Could not find a list with this pk."))
14670- } else {
14671- err.into()
14672- }
14673- })?;
14674-
14675- trace!("add_template {:?}.", &ret);
14676- Ok(ret)
14677- }
14678-
14679- /// Remove a named template.
14680- pub fn remove_template(&self, template: &str, list_pk: Option<i64>) -> Result<Template> {
14681- let mut stmt = self
14682- .connection
14683- .prepare("DELETE FROM template WHERE name = ? AND list IS ? RETURNING *;")?;
14684- let ret = stmt.query_row(rusqlite::params![&template, &list_pk], |row| {
14685- Ok(Template {
14686- pk: -1,
14687- name: row.get("name")?,
14688- list: row.get("list")?,
14689- subject: row.get("subject")?,
14690- headers_json: row.get("headers_json")?,
14691- body: row.get("body")?,
14692- })
14693- })?;
14694-
14695- trace!(
14696- "remove_template {} list_pk {:?} {:?}.",
14697- template,
14698- &list_pk,
14699- &ret
14700- );
14701- Ok(ret)
14702- }
14703- }
14704 diff --git a/core/tests/account.rs b/core/tests/account.rs
14705deleted file mode 100644
14706index f02a05f..0000000
14707--- a/core/tests/account.rs
14708+++ /dev/null
14709 @@ -1,145 +0,0 @@
14710- /*
14711- * This file is part of mailpot
14712- *
14713- * Copyright 2020 - Manos Pitsidianakis
14714- *
14715- * This program is free software: you can redistribute it and/or modify
14716- * it under the terms of the GNU Affero General Public License as
14717- * published by the Free Software Foundation, either version 3 of the
14718- * License, or (at your option) any later version.
14719- *
14720- * This program is distributed in the hope that it will be useful,
14721- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14722- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14723- * GNU Affero General Public License for more details.
14724- *
14725- * You should have received a copy of the GNU Affero General Public License
14726- * along with this program. If not, see <https://www.gnu.org/licenses/>.
14727- */
14728-
14729- use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
14730- use mailpot_tests::init_stderr_logging;
14731- use tempfile::TempDir;
14732-
14733- #[test]
14734- fn test_accounts() {
14735- init_stderr_logging();
14736-
14737- const SSH_KEY: &[u8] = include_bytes!("./ssh_key.pub");
14738-
14739- let tmp_dir = TempDir::new().unwrap();
14740-
14741- let db_path = tmp_dir.path().join("mpot.db");
14742- let config = Configuration {
14743- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
14744- db_path,
14745- data_path: tmp_dir.path().to_path_buf(),
14746- administrators: vec![],
14747- };
14748-
14749- let db = Connection::open_or_create_db(config).unwrap().trusted();
14750- assert!(db.lists().unwrap().is_empty());
14751- let foo_chat = db
14752- .create_list(MailingList {
14753- pk: 0,
14754- name: "foobar chat".into(),
14755- id: "foo-chat".into(),
14756- address: "foo-chat@example.com".into(),
14757- description: None,
14758- topics: vec![],
14759- archive_url: None,
14760- })
14761- .unwrap();
14762-
14763- assert_eq!(foo_chat.pk(), 1);
14764- let lists = db.lists().unwrap();
14765- assert_eq!(lists.len(), 1);
14766- assert_eq!(lists[0], foo_chat);
14767- let post_policy = db
14768- .set_list_post_policy(PostPolicy {
14769- pk: 0,
14770- list: foo_chat.pk(),
14771- announce_only: false,
14772- subscription_only: true,
14773- approval_needed: false,
14774- open: false,
14775- custom: false,
14776- })
14777- .unwrap();
14778-
14779- assert_eq!(post_policy.pk(), 1);
14780- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
14781- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
14782-
14783- let db = db.untrusted();
14784-
14785- let subscribe_bytes = b"From: Name <user@example.com>
14786- To: <foo-chat+subscribe@example.com>
14787- Subject: subscribe
14788- Date: Thu, 29 Oct 2020 13:58:16 +0000
14789- Message-ID: <abcdefgh@sator.example.com>
14790- Content-Language: en-US
14791- Content-Type: text/html
14792- Content-Transfer-Encoding: base64
14793- MIME-Version: 1.0
14794-
14795- ";
14796- let envelope =
14797- melib::Envelope::from_bytes(subscribe_bytes, None).expect("Could not parse message");
14798- db.post(&envelope, subscribe_bytes, /* dry_run */ false)
14799- .unwrap();
14800- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
14801- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
14802-
14803- assert_eq!(db.account_by_address("user@example.com").unwrap(), None);
14804-
14805- println!(
14806- "Check that sending a password request without having an account creates the account."
14807- );
14808- const PASSWORD_REQ: &[u8] = b"From: Name <user@example.com>
14809- To: <foo-chat+request@example.com>
14810- Subject: password
14811- Date: Thu, 29 Oct 2020 13:58:16 +0000
14812- Message-ID: <abcdefgh@sator.example.com>
14813- Content-Language: en-US
14814- Content-Type: text/plain; charset=ascii
14815- Content-Transfer-Encoding: 8bit
14816- MIME-Version: 1.0
14817-
14818- ";
14819- let mut set_password_bytes = PASSWORD_REQ.to_vec();
14820- set_password_bytes.extend(SSH_KEY.iter().cloned());
14821-
14822- let envelope =
14823- melib::Envelope::from_bytes(&set_password_bytes, None).expect("Could not parse message");
14824- db.post(&envelope, &set_password_bytes, /* dry_run */ false)
14825- .unwrap();
14826- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
14827- let acc = db.account_by_address("user@example.com").unwrap().unwrap();
14828-
14829- assert_eq!(
14830- acc.password.as_bytes(),
14831- SSH_KEY,
14832- "SSH public key / passwords didn't match. Account has {:?} but expected {:?}",
14833- String::from_utf8_lossy(acc.password.as_bytes()),
14834- String::from_utf8_lossy(SSH_KEY)
14835- );
14836-
14837- println!("Check that sending a password request with an account updates the password field.");
14838-
14839- let mut set_password_bytes = PASSWORD_REQ.to_vec();
14840- set_password_bytes.push(b'a');
14841- set_password_bytes.extend(SSH_KEY.iter().cloned());
14842-
14843- let envelope =
14844- melib::Envelope::from_bytes(&set_password_bytes, None).expect("Could not parse message");
14845- db.post(&envelope, &set_password_bytes, /* dry_run */ false)
14846- .unwrap();
14847- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
14848- let acc = db.account_by_address("user@example.com").unwrap().unwrap();
14849-
14850- assert!(
14851- acc.password.as_bytes() != SSH_KEY,
14852- "SSH public key / password should have changed.",
14853- );
14854- }
14855 diff --git a/core/tests/authorizer.rs b/core/tests/authorizer.rs
14856deleted file mode 100644
14857index f4e124a..0000000
14858--- a/core/tests/authorizer.rs
14859+++ /dev/null
14860 @@ -1,113 +0,0 @@
14861- /*
14862- * This file is part of mailpot
14863- *
14864- * Copyright 2020 - Manos Pitsidianakis
14865- *
14866- * This program is free software: you can redistribute it and/or modify
14867- * it under the terms of the GNU Affero General Public License as
14868- * published by the Free Software Foundation, either version 3 of the
14869- * License, or (at your option) any later version.
14870- *
14871- * This program is distributed in the hope that it will be useful,
14872- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14873- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14874- * GNU Affero General Public License for more details.
14875- *
14876- * You should have received a copy of the GNU Affero General Public License
14877- * along with this program. If not, see <https://www.gnu.org/licenses/>.
14878- */
14879-
14880- use mailpot::{models::*, Configuration, Connection, ErrorKind, SendMail};
14881- use mailpot_tests::init_stderr_logging;
14882- use tempfile::TempDir;
14883-
14884- #[test]
14885- fn test_authorizer() {
14886- init_stderr_logging();
14887- let tmp_dir = TempDir::new().unwrap();
14888-
14889- let db_path = tmp_dir.path().join("mpot.db");
14890- let config = Configuration {
14891- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
14892- db_path,
14893- data_path: tmp_dir.path().to_path_buf(),
14894- administrators: vec![],
14895- };
14896-
14897- let db = Connection::open_or_create_db(config).unwrap();
14898- assert!(db.lists().unwrap().is_empty());
14899-
14900- for err in [
14901- db.create_list(MailingList {
14902- pk: 0,
14903- name: "foobar chat".into(),
14904- id: "foo-chat".into(),
14905- address: "foo-chat@example.com".into(),
14906- description: None,
14907- topics: vec![],
14908- archive_url: None,
14909- })
14910- .unwrap_err(),
14911- db.remove_list_owner(1, 1).unwrap_err(),
14912- db.remove_list_post_policy(1, 1).unwrap_err(),
14913- db.set_list_post_policy(PostPolicy {
14914- pk: 0,
14915- list: 1,
14916- announce_only: false,
14917- subscription_only: true,
14918- approval_needed: false,
14919- open: false,
14920- custom: false,
14921- })
14922- .unwrap_err(),
14923- ] {
14924- assert_eq!(
14925- err.kind().to_string(),
14926- ErrorKind::Sql(rusqlite::Error::SqliteFailure(
14927- rusqlite::ffi::Error {
14928- code: rusqlite::ErrorCode::AuthorizationForStatementDenied,
14929- extended_code: 23,
14930- },
14931- Some("not authorized".into()),
14932- ))
14933- .to_string()
14934- );
14935- }
14936- assert!(db.lists().unwrap().is_empty());
14937-
14938- let db = db.trusted();
14939-
14940- for ok in [
14941- db.create_list(MailingList {
14942- pk: 0,
14943- name: "foobar chat".into(),
14944- id: "foo-chat".into(),
14945- address: "foo-chat@example.com".into(),
14946- description: None,
14947- topics: vec![],
14948- archive_url: None,
14949- })
14950- .map(|_| ()),
14951- db.add_list_owner(ListOwner {
14952- pk: 0,
14953- list: 1,
14954- address: String::new(),
14955- name: None,
14956- })
14957- .map(|_| ()),
14958- db.set_list_post_policy(PostPolicy {
14959- pk: 0,
14960- list: 1,
14961- announce_only: false,
14962- subscription_only: true,
14963- approval_needed: false,
14964- open: false,
14965- custom: false,
14966- })
14967- .map(|_| ()),
14968- db.remove_list_post_policy(1, 1).map(|_| ()),
14969- db.remove_list_owner(1, 1).map(|_| ()),
14970- ] {
14971- ok.unwrap();
14972- }
14973- }
14974 diff --git a/core/tests/creation.rs b/core/tests/creation.rs
14975deleted file mode 100644
14976index 31aa0cc..0000000
14977--- a/core/tests/creation.rs
14978+++ /dev/null
14979 @@ -1,73 +0,0 @@
14980- /*
14981- * This file is part of mailpot
14982- *
14983- * Copyright 2020 - Manos Pitsidianakis
14984- *
14985- * This program is free software: you can redistribute it and/or modify
14986- * it under the terms of the GNU Affero General Public License as
14987- * published by the Free Software Foundation, either version 3 of the
14988- * License, or (at your option) any later version.
14989- *
14990- * This program is distributed in the hope that it will be useful,
14991- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14992- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14993- * GNU Affero General Public License for more details.
14994- *
14995- * You should have received a copy of the GNU Affero General Public License
14996- * along with this program. If not, see <https://www.gnu.org/licenses/>.
14997- */
14998-
14999- use mailpot::{models::*, Configuration, Connection, SendMail};
15000- use mailpot_tests::init_stderr_logging;
15001- use tempfile::TempDir;
15002-
15003- #[test]
15004- fn test_init_empty() {
15005- init_stderr_logging();
15006- let tmp_dir = TempDir::new().unwrap();
15007-
15008- let db_path = tmp_dir.path().join("mpot.db");
15009- let config = Configuration {
15010- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
15011- db_path,
15012- data_path: tmp_dir.path().to_path_buf(),
15013- administrators: vec![],
15014- };
15015-
15016- let db = Connection::open_or_create_db(config).unwrap();
15017-
15018- assert!(db.lists().unwrap().is_empty());
15019- }
15020-
15021- #[test]
15022- fn test_list_creation() {
15023- init_stderr_logging();
15024- let tmp_dir = TempDir::new().unwrap();
15025-
15026- let db_path = tmp_dir.path().join("mpot.db");
15027- let config = Configuration {
15028- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
15029- db_path,
15030- data_path: tmp_dir.path().to_path_buf(),
15031- administrators: vec![],
15032- };
15033-
15034- let db = Connection::open_or_create_db(config).unwrap().trusted();
15035- assert!(db.lists().unwrap().is_empty());
15036- let foo_chat = db
15037- .create_list(MailingList {
15038- pk: 0,
15039- name: "foobar chat".into(),
15040- id: "foo-chat".into(),
15041- address: "foo-chat@example.com".into(),
15042- description: None,
15043- topics: vec![],
15044- archive_url: None,
15045- })
15046- .unwrap();
15047-
15048- assert_eq!(foo_chat.pk(), 1);
15049- let lists = db.lists().unwrap();
15050- assert_eq!(lists.len(), 1);
15051- assert_eq!(lists[0], foo_chat);
15052- }
15053 diff --git a/core/tests/error_queue.rs b/core/tests/error_queue.rs
15054deleted file mode 100644
15055index ed8a117..0000000
15056--- a/core/tests/error_queue.rs
15057+++ /dev/null
15058 @@ -1,96 +0,0 @@
15059- /*
15060- * This file is part of mailpot
15061- *
15062- * Copyright 2020 - Manos Pitsidianakis
15063- *
15064- * This program is free software: you can redistribute it and/or modify
15065- * it under the terms of the GNU Affero General Public License as
15066- * published by the Free Software Foundation, either version 3 of the
15067- * License, or (at your option) any later version.
15068- *
15069- * This program is distributed in the hope that it will be useful,
15070- * but WITHOUT ANY WARRANTY; without even the implied warranty of
15071- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15072- * GNU Affero General Public License for more details.
15073- *
15074- * You should have received a copy of the GNU Affero General Public License
15075- * along with this program. If not, see <https://www.gnu.org/licenses/>.
15076- */
15077-
15078- use mailpot::{melib, models::*, queue::Queue, Configuration, Connection, SendMail};
15079- use mailpot_tests::init_stderr_logging;
15080- use tempfile::TempDir;
15081-
15082- fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
15083- use melib::smtp::*;
15084- SmtpServerConf {
15085- hostname: "127.0.0.1".into(),
15086- port: 8825,
15087- envelope_from: "foo-chat@example.com".into(),
15088- auth: SmtpAuth::None,
15089- security: SmtpSecurity::None,
15090- extensions: Default::default(),
15091- }
15092- }
15093-
15094- #[test]
15095- fn test_error_queue() {
15096- init_stderr_logging();
15097- let tmp_dir = TempDir::new().unwrap();
15098-
15099- let db_path = tmp_dir.path().join("mpot.db");
15100- let config = Configuration {
15101- send_mail: SendMail::Smtp(get_smtp_conf()),
15102- db_path,
15103- data_path: tmp_dir.path().to_path_buf(),
15104- administrators: vec![],
15105- };
15106-
15107- let db = Connection::open_or_create_db(config).unwrap().trusted();
15108- assert!(db.lists().unwrap().is_empty());
15109- let foo_chat = db
15110- .create_list(MailingList {
15111- pk: 0,
15112- name: "foobar chat".into(),
15113- id: "foo-chat".into(),
15114- address: "foo-chat@example.com".into(),
15115- description: None,
15116- topics: vec![],
15117- archive_url: None,
15118- })
15119- .unwrap();
15120-
15121- assert_eq!(foo_chat.pk(), 1);
15122- let post_policy = db
15123- .set_list_post_policy(PostPolicy {
15124- pk: 0,
15125- list: foo_chat.pk(),
15126- announce_only: false,
15127- subscription_only: true,
15128- approval_needed: false,
15129- open: false,
15130- custom: false,
15131- })
15132- .unwrap();
15133-
15134- assert_eq!(post_policy.pk(), 1);
15135- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
15136-
15137- // drop privileges
15138- let db = db.untrusted();
15139-
15140- let input_bytes = include_bytes!("./test_sample_longmessage.eml");
15141- let envelope = melib::Envelope::from_bytes(input_bytes, None).expect("Could not parse message");
15142- db.post(&envelope, input_bytes, /* dry_run */ false)
15143- .expect("Got unexpected error");
15144- let out = db.queue(Queue::Out).unwrap();
15145- assert_eq!(out.len(), 1);
15146- const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
15147- assert_eq!(
15148- out[0]
15149- .comment
15150- .as_ref()
15151- .and_then(|c| c.get(..COMMENT_PREFIX.len())),
15152- Some(COMMENT_PREFIX)
15153- );
15154- }
15155 diff --git a/core/tests/migrations.rs b/core/tests/migrations.rs
15156deleted file mode 100644
15157index 69d8da6..0000000
15158--- a/core/tests/migrations.rs
15159+++ /dev/null
15160 @@ -1,343 +0,0 @@
15161- /*
15162- * This file is part of mailpot
15163- *
15164- * Copyright 2020 - Manos Pitsidianakis
15165- *
15166- * This program is free software: you can redistribute it and/or modify
15167- * it under the terms of the GNU Affero General Public License as
15168- * published by the Free Software Foundation, either version 3 of the
15169- * License, or (at your option) any later version.
15170- *
15171- * This program is distributed in the hope that it will be useful,
15172- * but WITHOUT ANY WARRANTY; without even the implied warranty of
15173- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15174- * GNU Affero General Public License for more details.
15175- *
15176- * You should have received a copy of the GNU Affero General Public License
15177- * along with this program. If not, see <https://www.gnu.org/licenses/>.
15178- */
15179-
15180- use std::fs::{File, OpenOptions};
15181-
15182- use mailpot::{Configuration, Connection, SendMail};
15183- use mailpot_tests::init_stderr_logging;
15184- use tempfile::TempDir;
15185-
15186- include!("../build/make_migrations.rs");
15187-
15188- #[test]
15189- fn test_init_empty() {
15190- init_stderr_logging();
15191- let tmp_dir = TempDir::new().unwrap();
15192-
15193- let db_path = tmp_dir.path().join("mpot.db");
15194- let config = Configuration {
15195- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
15196- db_path,
15197- data_path: tmp_dir.path().to_path_buf(),
15198- administrators: vec![],
15199- };
15200-
15201- let db = Connection::open_or_create_db(config).unwrap().trusted();
15202-
15203- let migrations = Connection::MIGRATIONS;
15204- if migrations.is_empty() {
15205- return;
15206- }
15207-
15208- let version = db.schema_version().unwrap();
15209-
15210- assert_eq!(version, migrations[migrations.len() - 1].0);
15211-
15212- db.migrate(version, migrations[0].0).unwrap();
15213-
15214- db.migrate(migrations[0].0, version).unwrap();
15215- }
15216-
15217- trait ConnectionExt {
15218- fn schema_version(&self) -> Result<u32, rusqlite::Error>;
15219- fn migrate(
15220- &mut self,
15221- from: u32,
15222- to: u32,
15223- migrations: &[(u32, &str, &str)],
15224- ) -> Result<(), rusqlite::Error>;
15225- }
15226-
15227- impl ConnectionExt for rusqlite::Connection {
15228- fn schema_version(&self) -> Result<u32, rusqlite::Error> {
15229- self.prepare("SELECT user_version FROM pragma_user_version;")?
15230- .query_row([], |row| {
15231- let v: u32 = row.get(0)?;
15232- Ok(v)
15233- })
15234- }
15235-
15236- fn migrate(
15237- &mut self,
15238- mut from: u32,
15239- to: u32,
15240- migrations: &[(u32, &str, &str)],
15241- ) -> Result<(), rusqlite::Error> {
15242- if from == to {
15243- return Ok(());
15244- }
15245-
15246- let undo = from > to;
15247- let tx = self.transaction()?;
15248-
15249- loop {
15250- log::trace!(
15251- "exec migration from {from} to {to}, type: {}do",
15252- if undo { "un" } else { "re" }
15253- );
15254- if undo {
15255- log::trace!("{}", migrations[from as usize - 1].2);
15256- tx.execute_batch(migrations[from as usize - 1].2)?;
15257- from -= 1;
15258- if from == to {
15259- break;
15260- }
15261- } else {
15262- if from != 0 {
15263- log::trace!("{}", migrations[from as usize - 1].1);
15264- tx.execute_batch(migrations[from as usize - 1].1)?;
15265- }
15266- from += 1;
15267- if from == to + 1 {
15268- break;
15269- }
15270- }
15271- }
15272- tx.pragma_update(
15273- None,
15274- "user_version",
15275- if to == 0 {
15276- 0
15277- } else {
15278- migrations[to as usize - 1].0
15279- },
15280- )?;
15281-
15282- tx.commit()?;
15283- Ok(())
15284- }
15285- }
15286-
15287- const FIRST_SCHEMA: &str = r#"
15288- PRAGMA foreign_keys = true;
15289- PRAGMA encoding = 'UTF-8';
15290- PRAGMA schema_version = 0;
15291-
15292- CREATE TABLE IF NOT EXISTS person (
15293- pk INTEGER PRIMARY KEY NOT NULL,
15294- name TEXT,
15295- address TEXT NOT NULL,
15296- created INTEGER NOT NULL DEFAULT (unixepoch()),
15297- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
15298- );
15299- "#;
15300-
15301- const MIGRATIONS: &[(u32, &str, &str)] = &[
15302- (
15303- 1,
15304- "ALTER TABLE PERSON ADD COLUMN interests TEXT;",
15305- "ALTER TABLE PERSON DROP COLUMN interests;",
15306- ),
15307- (
15308- 2,
15309- "CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);",
15310- "DROP TABLE hobby;",
15311- ),
15312- (
15313- 3,
15314- "ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;",
15315- "ALTER TABLE PERSON DROP COLUMN main_hobby;",
15316- ),
15317- ];
15318-
15319- #[test]
15320- fn test_migration_gen() {
15321- init_stderr_logging();
15322- let tmp_dir = TempDir::new().unwrap();
15323- let in_path = tmp_dir.path().join("migrations");
15324- std::fs::create_dir(&in_path).unwrap();
15325- let out_path = tmp_dir.path().join("migrations.txt");
15326- for (num, redo, undo) in MIGRATIONS.iter() {
15327- let mut redo_file = File::options()
15328- .write(true)
15329- .create(true)
15330- .truncate(true)
15331- .open(&in_path.join(&format!("{num:03}.sql")))
15332- .unwrap();
15333- redo_file.write_all(redo.as_bytes()).unwrap();
15334- redo_file.flush().unwrap();
15335-
15336- let mut undo_file = File::options()
15337- .write(true)
15338- .create(true)
15339- .truncate(true)
15340- .open(&in_path.join(&format!("{num:03}.undo.sql")))
15341- .unwrap();
15342- undo_file.write_all(undo.as_bytes()).unwrap();
15343- undo_file.flush().unwrap();
15344- }
15345-
15346- make_migrations(&in_path, &out_path, &mut vec![]);
15347- let output = std::fs::read_to_string(&out_path).unwrap();
15348- assert_eq!(&output.replace([' ', '\n'], ""), &r###"//(user_version, redo sql, undo sql
15349- &[(1,r##"ALTER TABLE PERSON ADD COLUMN interests TEXT;"##,r##"ALTER TABLE PERSON DROP COLUMN interests;"##),(2,r##"CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);"##,r##"DROP TABLE hobby;"##),(3,r##"ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;"##,r##"ALTER TABLE PERSON DROP COLUMN main_hobby;"##),]"###.replace([' ', '\n'], ""));
15350- }
15351-
15352- #[test]
15353- #[should_panic]
15354- fn test_migration_gen_panic() {
15355- init_stderr_logging();
15356- let tmp_dir = TempDir::new().unwrap();
15357- let in_path = tmp_dir.path().join("migrations");
15358- std::fs::create_dir(&in_path).unwrap();
15359- let out_path = tmp_dir.path().join("migrations.txt");
15360- for (num, redo, undo) in MIGRATIONS.iter().skip(1) {
15361- let mut redo_file = File::options()
15362- .write(true)
15363- .create(true)
15364- .truncate(true)
15365- .open(&in_path.join(&format!("{num:03}.sql")))
15366- .unwrap();
15367- redo_file.write_all(redo.as_bytes()).unwrap();
15368- redo_file.flush().unwrap();
15369-
15370- let mut undo_file = File::options()
15371- .write(true)
15372- .create(true)
15373- .truncate(true)
15374- .open(&in_path.join(&format!("{num:03}.undo.sql")))
15375- .unwrap();
15376- undo_file.write_all(undo.as_bytes()).unwrap();
15377- undo_file.flush().unwrap();
15378- }
15379-
15380- make_migrations(&in_path, &out_path, &mut vec![]);
15381- let output = std::fs::read_to_string(&out_path).unwrap();
15382- assert_eq!(&output.replace([' ','\n'], ""), &r#"//(user_version, redo sql, undo sql
15383- &[(1,"ALTER TABLE PERSON ADD COLUMN interests TEXT;","ALTER TABLE PERSON DROP COLUMN interests;"),(2,"CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);","DROP TABLE hobby;"),(3,"ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;","ALTER TABLE PERSON DROP COLUMN main_hobby;"),]"#.replace([' ', '\n'], ""));
15384- }
15385-
15386- #[test]
15387- fn test_migration() {
15388- init_stderr_logging();
15389- let tmp_dir = TempDir::new().unwrap();
15390- let db_path = tmp_dir.path().join("migr.db");
15391-
15392- let mut conn = rusqlite::Connection::open(db_path.to_str().unwrap()).unwrap();
15393- conn.execute_batch(FIRST_SCHEMA).unwrap();
15394-
15395- conn.execute_batch(
15396- "INSERT INTO person(name,address) VALUES('John Doe', 'johndoe@example.com');",
15397- )
15398- .unwrap();
15399-
15400- let version = conn.schema_version().unwrap();
15401- log::trace!("initial schema version is {}", version);
15402-
15403- //assert_eq!(version, migrations[migrations.len() - 1].0);
15404-
15405- conn.migrate(version, MIGRATIONS.last().unwrap().0, MIGRATIONS)
15406- .unwrap();
15407- /*
15408- * CREATE TABLE sqlite_schema (
15409- type text,
15410- name text,
15411- tbl_name text,
15412- rootpage integer,
15413- sql text
15414- );
15415- */
15416- let get_sql = |table: &str, conn: &rusqlite::Connection| -> String {
15417- conn.prepare("SELECT sql FROM sqlite_schema WHERE name = ?;")
15418- .unwrap()
15419- .query_row([table], |row| {
15420- let sql: String = row.get(0)?;
15421- Ok(sql)
15422- })
15423- .unwrap()
15424- };
15425-
15426- let strip_ws = |sql: &str| -> String { sql.replace([' ', '\n'], "") };
15427-
15428- let person_sql: String = get_sql("person", &conn);
15429- assert_eq!(
15430- &strip_ws(&person_sql),
15431- &strip_ws(
15432- r#"
15433- CREATE TABLE person (
15434- pk INTEGER PRIMARY KEY NOT NULL,
15435- name TEXT,
15436- address TEXT NOT NULL,
15437- created INTEGER NOT NULL DEFAULT (unixepoch()),
15438- last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
15439- interests TEXT,
15440- main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL
15441- )"#
15442- )
15443- );
15444- let hobby_sql: String = get_sql("hobby", &conn);
15445- assert_eq!(
15446- &strip_ws(&hobby_sql),
15447- &strip_ws(
15448- r#"CREATE TABLE hobby (
15449- pk INTEGER PRIMARY KEY NOT NULL,
15450- title TEXT NOT NULL
15451- )"#
15452- )
15453- );
15454- conn.execute_batch(
15455- r#"
15456- INSERT INTO hobby(title) VALUES('fishing');
15457- INSERT INTO hobby(title) VALUES('reading books');
15458- INSERT INTO hobby(title) VALUES('running');
15459- INSERT INTO hobby(title) VALUES('forest walks');
15460- UPDATE person SET main_hobby = hpk FROM (SELECT pk AS hpk FROM hobby LIMIT 1) WHERE name = 'John Doe';
15461- "#
15462- )
15463- .unwrap();
15464- log::trace!(
15465- "John Doe's main hobby is {:?}",
15466- conn.prepare(
15467- "SELECT pk, title FROM hobby WHERE EXISTS (SELECT 1 FROM person AS p WHERE \
15468- p.main_hobby = pk);"
15469- )
15470- .unwrap()
15471- .query_row([], |row| {
15472- let pk: i64 = row.get(0)?;
15473- let title: String = row.get(1)?;
15474- Ok((pk, title))
15475- })
15476- .unwrap()
15477- );
15478-
15479- conn.migrate(MIGRATIONS.last().unwrap().0, 0, MIGRATIONS)
15480- .unwrap();
15481-
15482- assert_eq!(
15483- conn.prepare("SELECT sql FROM sqlite_schema WHERE name = 'hobby';")
15484- .unwrap()
15485- .query_row([], |row| { row.get::<_, String>(0) })
15486- .unwrap_err(),
15487- rusqlite::Error::QueryReturnedNoRows
15488- );
15489- let person_sql: String = get_sql("person", &conn);
15490- assert_eq!(
15491- &strip_ws(&person_sql),
15492- &strip_ws(
15493- r#"
15494- CREATE TABLE person (
15495- pk INTEGER PRIMARY KEY NOT NULL,
15496- name TEXT,
15497- address TEXT NOT NULL,
15498- created INTEGER NOT NULL DEFAULT (unixepoch()),
15499- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
15500- )"#
15501- )
15502- );
15503- }
15504 diff --git a/core/tests/settings_json.rs b/core/tests/settings_json.rs
15505deleted file mode 100644
15506index 82d459d..0000000
15507--- a/core/tests/settings_json.rs
15508+++ /dev/null
15509 @@ -1,223 +0,0 @@
15510- /*
15511- * This file is part of mailpot
15512- *
15513- * Copyright 2023 - Manos Pitsidianakis
15514- *
15515- * This program is free software: you can redistribute it and/or modify
15516- * it under the terms of the GNU Affero General Public License as
15517- * published by the Free Software Foundation, either version 3 of the
15518- * License, or (at your option) any later version.
15519- *
15520- * This program is distributed in the hope that it will be useful,
15521- * but WITHOUT ANY WARRANTY; without even the implied warranty of
15522- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15523- * GNU Affero General Public License for more details.
15524- *
15525- * You should have received a copy of the GNU Affero General Public License
15526- * along with this program. If not, see <https://www.gnu.org/licenses/>.
15527- */
15528-
15529- use jsonschema::JSONSchema;
15530- use mailpot::{Configuration, Connection, SendMail};
15531- use mailpot_tests::init_stderr_logging;
15532- use serde_json::{json, Value};
15533- use tempfile::TempDir;
15534-
15535- #[test]
15536- fn test_settings_json() {
15537- init_stderr_logging();
15538- let tmp_dir = TempDir::new().unwrap();
15539-
15540- let db_path = tmp_dir.path().join("mpot.db");
15541- std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
15542- let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
15543- #[allow(clippy::permissions_set_readonly_false)]
15544- perms.set_readonly(false);
15545- std::fs::set_permissions(&db_path, perms).unwrap();
15546-
15547- let config = Configuration {
15548- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
15549- db_path,
15550- data_path: tmp_dir.path().to_path_buf(),
15551- administrators: vec![],
15552- };
15553- let db = Connection::open_or_create_db(config).unwrap().trusted();
15554- let list = db.lists().unwrap().remove(0);
15555-
15556- let archived_at_link_settings_schema =
15557- std::fs::read_to_string("./settings_json_schemas/archivedatlink.json").unwrap();
15558-
15559- println!("Testing that inserting settings works…");
15560- let (settings_pk, settings_val, last_modified): (i64, Value, i64) = {
15561- let mut stmt = db
15562- .connection
15563- .prepare(
15564- "INSERT INTO list_settings_json(name, list, value) \
15565- VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value, last_modified;",
15566- )
15567- .unwrap();
15568- stmt.query_row(
15569- rusqlite::params![
15570- &list.pk(),
15571- &json!({
15572- "template": "https://www.example.com/{{msg_id}}.html",
15573- "preserve_carets": false
15574- }),
15575- ],
15576- |row| {
15577- let pk: i64 = row.get("pk")?;
15578- let value: Value = row.get("value")?;
15579- let last_modified: i64 = row.get("last_modified")?;
15580- Ok((pk, value, last_modified))
15581- },
15582- )
15583- .unwrap()
15584- };
15585- db.connection
15586- .execute_batch("UPDATE list_settings_json SET is_valid = 1;")
15587- .unwrap();
15588-
15589- println!("Testing that schema is actually valid…");
15590- let schema: Value = serde_json::from_str(&archived_at_link_settings_schema).unwrap();
15591- let compiled = JSONSchema::compile(&schema).expect("A valid schema");
15592- if let Err(errors) = compiled.validate(&settings_val) {
15593- for err in errors {
15594- eprintln!("Error: {err}");
15595- }
15596- panic!("Could not validate settings.");
15597- };
15598-
15599- println!("Testing that inserting invalid settings aborts…");
15600- {
15601- let mut stmt = db
15602- .connection
15603- .prepare(
15604- "INSERT OR REPLACE INTO list_settings_json(name, list, value) \
15605- VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value;",
15606- )
15607- .unwrap();
15608- assert_eq!(
15609- "new settings value is not valid according to the json schema. Rolling back \
15610- transaction.",
15611- &stmt
15612- .query_row(
15613- rusqlite::params![
15614- &list.pk(),
15615- &json!({
15616- "template": "https://www.example.com/msg-id}.html" // should be msg_id
15617- }),
15618- ],
15619- |row| {
15620- let pk: i64 = row.get("pk")?;
15621- let value: Value = row.get("value")?;
15622- Ok((pk, value))
15623- },
15624- )
15625- .unwrap_err()
15626- .to_string()
15627- );
15628- };
15629-
15630- println!("Testing that updating settings with invalid value aborts…");
15631- {
15632- let mut stmt = db
15633- .connection
15634- .prepare(
15635- "UPDATE list_settings_json SET value = ? WHERE name = 'ArchivedAtLinkSettings' \
15636- RETURNING pk, value;",
15637- )
15638- .unwrap();
15639- assert_eq!(
15640- "new settings value is not valid according to the json schema. Rolling back \
15641- transaction.",
15642- &stmt
15643- .query_row(
15644- rusqlite::params![&json!({
15645- "template": "https://www.example.com/msg-id}.html" // should be msg_id
15646- }),],
15647- |row| {
15648- let pk: i64 = row.get("pk")?;
15649- let value: Value = row.get("value")?;
15650- Ok((pk, value))
15651- },
15652- )
15653- .unwrap_err()
15654- .to_string()
15655- );
15656- };
15657-
15658- std::thread::sleep(std::time::Duration::from_millis(1000));
15659- println!("Finally, testing that updating schema reverifies settings…");
15660- {
15661- let mut stmt = db
15662- .connection
15663- .prepare(
15664- "UPDATE settings_json_schema SET id = ? WHERE id = 'ArchivedAtLinkSettings' \
15665- RETURNING pk;",
15666- )
15667- .unwrap();
15668- stmt.query_row([&"ArchivedAtLinkSettingsv2"], |_| Ok(()))
15669- .unwrap();
15670- };
15671- let (new_name, is_valid, new_last_modified): (String, bool, i64) = {
15672- let mut stmt = db
15673- .connection
15674- .prepare("SELECT name, is_valid, last_modified from list_settings_json WHERE pk = ?;")
15675- .unwrap();
15676- stmt.query_row([&settings_pk], |row| {
15677- Ok((
15678- row.get("name")?,
15679- row.get("is_valid")?,
15680- row.get("last_modified")?,
15681- ))
15682- })
15683- .unwrap()
15684- };
15685- assert_eq!(&new_name, "ArchivedAtLinkSettingsv2");
15686- assert!(is_valid);
15687- assert!(new_last_modified != last_modified);
15688- }
15689-
15690- #[test]
15691- fn test_settings_json_schemas() {
15692- init_stderr_logging();
15693- let tmp_dir = TempDir::new().unwrap();
15694-
15695- let db_path = tmp_dir.path().join("mpot.db");
15696- std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
15697- let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
15698- #[allow(clippy::permissions_set_readonly_false)]
15699- perms.set_readonly(false);
15700- std::fs::set_permissions(&db_path, perms).unwrap();
15701-
15702- let config = Configuration {
15703- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
15704- db_path,
15705- data_path: tmp_dir.path().to_path_buf(),
15706- administrators: vec![],
15707- };
15708- let db = Connection::open_or_create_db(config).unwrap().trusted();
15709-
15710- let schemas: Vec<String> = {
15711- let mut stmt = db
15712- .connection
15713- .prepare("SELECT value FROM list_settings_json;")
15714- .unwrap();
15715- let iter = stmt
15716- .query_map([], |row| {
15717- let value: String = row.get("value")?;
15718- Ok(value)
15719- })
15720- .unwrap();
15721- let mut ret = vec![];
15722- for item in iter {
15723- ret.push(item.unwrap());
15724- }
15725- ret
15726- };
15727- println!("Testing that schemas are valid…");
15728- for schema in schemas {
15729- let schema: Value = serde_json::from_str(&schema).unwrap();
15730- let _compiled = JSONSchema::compile(&schema).expect("A valid schema");
15731- }
15732- }
15733 diff --git a/core/tests/smtp.rs b/core/tests/smtp.rs
15734deleted file mode 100644
15735index 6fc84d9..0000000
15736--- a/core/tests/smtp.rs
15737+++ /dev/null
15738 @@ -1,284 +0,0 @@
15739- /*
15740- * This file is part of mailpot
15741- *
15742- * Copyright 2020 - Manos Pitsidianakis
15743- *
15744- * This program is free software: you can redistribute it and/or modify
15745- * it under the terms of the GNU Affero General Public License as
15746- * published by the Free Software Foundation, either version 3 of the
15747- * License, or (at your option) any later version.
15748- *
15749- * This program is distributed in the hope that it will be useful,
15750- * but WITHOUT ANY WARRANTY; without even the implied warranty of
15751- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15752- * GNU Affero General Public License for more details.
15753- *
15754- * You should have received a copy of the GNU Affero General Public License
15755- * along with this program. If not, see <https://www.gnu.org/licenses/>.
15756- */
15757-
15758- use log::{trace, warn};
15759- use mailpot::{melib, models::*, queue::Queue, Configuration, Connection, SendMail};
15760- use mailpot_tests::*;
15761- use melib::smol;
15762- use tempfile::TempDir;
15763-
15764- #[test]
15765- fn test_smtp() {
15766- init_stderr_logging();
15767-
15768- let tmp_dir = TempDir::new().unwrap();
15769-
15770- let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8825").build();
15771-
15772- let db_path = tmp_dir.path().join("mpot.db");
15773- let config = Configuration {
15774- send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
15775- db_path,
15776- data_path: tmp_dir.path().to_path_buf(),
15777- administrators: vec![],
15778- };
15779-
15780- let db = Connection::open_or_create_db(config).unwrap().trusted();
15781- assert!(db.lists().unwrap().is_empty());
15782- let foo_chat = db
15783- .create_list(MailingList {
15784- pk: 0,
15785- name: "foobar chat".into(),
15786- id: "foo-chat".into(),
15787- address: "foo-chat@example.com".into(),
15788- description: None,
15789- topics: vec![],
15790- archive_url: None,
15791- })
15792- .unwrap();
15793-
15794- assert_eq!(foo_chat.pk(), 1);
15795- let post_policy = db
15796- .set_list_post_policy(PostPolicy {
15797- pk: 0,
15798- list: foo_chat.pk(),
15799- announce_only: false,
15800- subscription_only: true,
15801- approval_needed: false,
15802- open: false,
15803- custom: false,
15804- })
15805- .unwrap();
15806-
15807- assert_eq!(post_policy.pk(), 1);
15808-
15809- let input_bytes = include_bytes!("./test_sample_longmessage.eml");
15810- match melib::Envelope::from_bytes(input_bytes, None) {
15811- Ok(envelope) => {
15812- // eprintln!("envelope {:?}", &envelope);
15813- db.post(&envelope, input_bytes, /* dry_run */ false)
15814- .expect("Got unexpected error");
15815- {
15816- let out = db.queue(Queue::Out).unwrap();
15817- assert_eq!(out.len(), 1);
15818- const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
15819- assert_eq!(
15820- out[0]
15821- .comment
15822- .as_ref()
15823- .and_then(|c| c.get(..COMMENT_PREFIX.len())),
15824- Some(COMMENT_PREFIX)
15825- );
15826- }
15827-
15828- db.add_subscription(
15829- foo_chat.pk(),
15830- ListSubscription {
15831- pk: 0,
15832- list: foo_chat.pk(),
15833- address: "paaoejunp@example.com".into(),
15834- name: Some("Cardholder Name".into()),
15835- account: None,
15836- digest: false,
15837- verified: true,
15838- hide_address: false,
15839- receive_duplicates: true,
15840- receive_own_posts: true,
15841- receive_confirmation: true,
15842- enabled: true,
15843- },
15844- )
15845- .unwrap();
15846- db.add_subscription(
15847- foo_chat.pk(),
15848- ListSubscription {
15849- pk: 0,
15850- list: foo_chat.pk(),
15851- address: "manos@example.com".into(),
15852- name: Some("Manos Hands".into()),
15853- account: None,
15854- digest: false,
15855- verified: true,
15856- hide_address: false,
15857- receive_duplicates: true,
15858- receive_own_posts: true,
15859- receive_confirmation: true,
15860- enabled: true,
15861- },
15862- )
15863- .unwrap();
15864- db.post(&envelope, input_bytes, /* dry_run */ false)
15865- .unwrap();
15866- }
15867- Err(err) => {
15868- panic!("Could not parse message: {}", err);
15869- }
15870- }
15871- let messages = db.delete_from_queue(Queue::Out, vec![]).unwrap();
15872- eprintln!("Queue out has {} messages.", messages.len());
15873- let conn_future = db.new_smtp_connection().unwrap();
15874- smol::future::block_on(smol::spawn(async move {
15875- let mut conn = conn_future.await.unwrap();
15876- for msg in messages {
15877- Connection::submit(&mut conn, &msg, /* dry_run */ false)
15878- .await
15879- .unwrap();
15880- }
15881- }));
15882- let stored = smtp_handler.stored.lock().unwrap();
15883- assert_eq!(stored.len(), 3);
15884- assert_eq!(&stored[0].0, "paaoejunp@example.com");
15885- assert_eq!(
15886- &stored[0].1.subject(),
15887- "Your post to foo-chat was rejected."
15888- );
15889- assert_eq!(
15890- &stored[1].1.subject(),
15891- "[foo-chat] thankful that I had the chance to written report, that I could learn and let \
15892- alone the chance $4454.32"
15893- );
15894- assert_eq!(
15895- &stored[2].1.subject(),
15896- "[foo-chat] thankful that I had the chance to written report, that I could learn and let \
15897- alone the chance $4454.32"
15898- );
15899- }
15900-
15901- #[test]
15902- fn test_smtp_mailcrab() {
15903- use std::env;
15904- init_stderr_logging();
15905-
15906- fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
15907- use melib::smtp::*;
15908- SmtpServerConf {
15909- hostname: "127.0.0.1".into(),
15910- port: 1025,
15911- envelope_from: "foo-chat@example.com".into(),
15912- auth: SmtpAuth::None,
15913- security: SmtpSecurity::None,
15914- extensions: Default::default(),
15915- }
15916- }
15917-
15918- let Ok(mailcrab_ip) = env::var("MAILCRAB_IP") else {
15919- warn!("MAILCRAB_IP env var not set, is mailcrab server running?");
15920- return;
15921- };
15922- let mailcrab_port = env::var("MAILCRAB_PORT").unwrap_or("1080".to_string());
15923- let api_uri = format!("http://{mailcrab_ip}:{mailcrab_port}/api/messages");
15924-
15925- let tmp_dir = TempDir::new().unwrap();
15926-
15927- let db_path = tmp_dir.path().join("mpot.db");
15928- let config = Configuration {
15929- send_mail: SendMail::Smtp(get_smtp_conf()),
15930- db_path,
15931- data_path: tmp_dir.path().to_path_buf(),
15932- administrators: vec![],
15933- };
15934-
15935- let db = Connection::open_or_create_db(config).unwrap().trusted();
15936- assert!(db.lists().unwrap().is_empty());
15937- let foo_chat = db
15938- .create_list(MailingList {
15939- pk: 0,
15940- name: "foobar chat".into(),
15941- id: "foo-chat".into(),
15942- address: "foo-chat@example.com".into(),
15943- description: None,
15944- topics: vec![],
15945- archive_url: None,
15946- })
15947- .unwrap();
15948-
15949- assert_eq!(foo_chat.pk(), 1);
15950- let post_policy = db
15951- .set_list_post_policy(PostPolicy {
15952- pk: 0,
15953- list: foo_chat.pk(),
15954- announce_only: false,
15955- subscription_only: true,
15956- approval_needed: false,
15957- open: false,
15958- custom: false,
15959- })
15960- .unwrap();
15961-
15962- assert_eq!(post_policy.pk(), 1);
15963-
15964- let input_bytes = include_bytes!("./test_sample_longmessage.eml");
15965- match melib::Envelope::from_bytes(input_bytes, None) {
15966- Ok(envelope) => {
15967- match db
15968- .post(&envelope, input_bytes, /* dry_run */ false)
15969- .unwrap_err()
15970- .kind()
15971- {
15972- mailpot::ErrorKind::PostRejected(reason) => {
15973- trace!("Non-subscription post succesfully rejected: '{reason}'");
15974- }
15975- other => panic!("Got unexpected error: {}", other),
15976- }
15977- db.add_subscription(
15978- foo_chat.pk(),
15979- ListSubscription {
15980- pk: 0,
15981- list: foo_chat.pk(),
15982- address: "paaoejunp@example.com".into(),
15983- name: Some("Cardholder Name".into()),
15984- account: None,
15985- digest: false,
15986- verified: true,
15987- hide_address: false,
15988- receive_duplicates: true,
15989- receive_own_posts: true,
15990- receive_confirmation: true,
15991- enabled: true,
15992- },
15993- )
15994- .unwrap();
15995- db.add_subscription(
15996- foo_chat.pk(),
15997- ListSubscription {
15998- pk: 0,
15999- list: foo_chat.pk(),
16000- address: "manos@example.com".into(),
16001- name: Some("Manos Hands".into()),
16002- account: None,
16003- digest: false,
16004- verified: true,
16005- hide_address: false,
16006- receive_duplicates: true,
16007- receive_own_posts: true,
16008- receive_confirmation: true,
16009- enabled: true,
16010- },
16011- )
16012- .unwrap();
16013- db.post(&envelope, input_bytes, /* dry_run */ false)
16014- .unwrap();
16015- }
16016- Err(err) => {
16017- panic!("Could not parse message: {}", err);
16018- }
16019- }
16020- let mails: String = reqwest::blocking::get(api_uri).unwrap().text().unwrap();
16021- trace!("mails: {}", mails);
16022- }
16023 diff --git a/core/tests/ssh_key b/core/tests/ssh_key
16024deleted file mode 100644
16025index 2ddec35..0000000
16026--- a/core/tests/ssh_key
16027+++ /dev/null
16028 @@ -1,38 +0,0 @@
16029- -----BEGIN OPENSSH PRIVATE KEY-----
16030- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
16031- NhAAAAAwEAAQAAAYEA9WwdJs/OhxhDoXqSCJHc3Ywrc3d2ATzfi8OVmlkm3kLSlGIOBefZ
16032- nWf0ew+mU8tWIg0+U6/skh9tDvZ8jv8V+jsFhlP257eWoMNj6C8rBoXVOr5aUXsvyiboO+
16033- G9ecu2W9KKDSXlOROA7ucmKx2sUqNdB6HwhnwhiC2Lqzm7utNVc9FLUkyArhW9NbdklsmS
16034- ocDPzl/WnE3l3xAsaTQTRzWXtXTjit27MqIsh7Ld9q+pqH5DYlam213STE/0Qv4GZdjLTd
16035- IRoHQ8VLZXsk8ppkRxUCYU4tNIydfwx/RxGG5f8wTbuy096CjJfDcxKsQLPOPPyzhStv3h
16036- nhHWIP8IIvPXfAUwoTG6o5Z7Czz0kl/CXOATvEStJccj6X13YmaIIDWSmc5JmelDGDj1GR
16037- 54G3GbimzrCG+nSrhfbwenPSefzcnxPSdROdo7SSt0fgMVxfOi+rVrsr4KWMQUq7e1LYgc
16038- Wir90g6W4V0S4dRRBnD0A9GuFRcpqPPnz+7oAH3tAAAFiKCeR3ygnkd8AAAAB3NzaC1yc2
16039- EAAAGBAPVsHSbPzocYQ6F6kgiR3N2MK3N3dgE834vDlZpZJt5C0pRiDgXn2Z1n9HsPplPL
16040- ViINPlOv7JIfbQ72fI7/Ffo7BYZT9ue3lqDDY+gvKwaF1Tq+WlF7L8om6DvhvXnLtlvSig
16041- 0l5TkTgO7nJisdrFKjXQeh8IZ8IYgti6s5u7rTVXPRS1JMgK4VvTW3ZJbJkqHAz85f1pxN
16042- 5d8QLGk0E0c1l7V044rduzKiLIey3favqah+Q2JWpttd0kxP9EL+BmXYy03SEaB0PFS2V7
16043- JPKaZEcVAmFOLTSMnX8Mf0cRhuX/ME27stPegoyXw3MSrECzzjz8s4Urb94Z4R1iD/CCLz
16044- 13wFMKExuqOWews89JJfwlzgE7xErSXHI+l9d2JmiCA1kpnOSZnpQxg49RkeeBtxm4ps6w
16045- hvp0q4X28Hpz0nn83J8T0nUTnaO0krdH4DFcXzovq1a7K+CljEFKu3tS2IHFoq/dIOluFd
16046- EuHUUQZw9APRrhUXKajz58/u6AB97QAAAAMBAAEAAAGBAJYL13bXLimiSBb93TKoGyTIgf
16047- hCXT88fF/y4BBR2VWh/SUDHhe2PHHkELD8THCGrM580lJQCI7976tqP5Udl845L5OE2jup
16048- HsqDKx3VWLTQNiGIJ6gRbJJnXyzdQv6n8YIKIqUPOim/JuDpKYjKx4RupH36IBfY5JdhYT
16049- b6QTBj7Ka2mxph83p7iAbDbRhTfPav71z5czh018mdFcnsMK0ksvAZ2tQX5E98n0UHsnUT
16050- yOJe78u7tp//qIdHiss6inRPKsWNkLk9fgzUAAfUu0GmJ5QCfu7RWVO6bXUk3TbgmxO40u
16051- jmubL97BQTniQqs/BRCYhIDj7bEX9+QB5ck2K9WseD2ODlBW3J87qkVfhix/oP6NES2X2s
16052- SHfNbDDagrbbweZJ96DXrRPpwV3u0Ez0iDEyxX4c++afT/vMN9kukIEf+GcHoJ2a+jmpZ7
16053- nDvX4qOBsYQQvaUMBjkaZX8rW/vmRk7ocX6OKZe+h/UjcusyDszxbAcJ+IbpW1bCAk8QAA
16054- AMEA7WBH3PksQx+8ibGHMstri6XWaB3U10SRm8NjW2CLmIdLPIn2QZ7+jhVLN6Lwj6pAOB
16055- J2ihYh9CnzKtJA7sPe8EUvoLFSR2eTzxU2blUcDPUF2etUi+6jZsaYIWo/OrFSs28KZaVB
16056- RsddoQbG2e9xaNWGqBVGogD1dgpAsdUau9kUcKjECxrtuzms97C9856rT9AjI3OroEBaVy
16057- tivu9JZ30bJE8AYB6+diDJBvFZQM+ihi95n7sZrz8kBXvUiPwhAAAAwQD9NimhT36bbKSx
16058- k7i6OCSzW079GOgr9YWeX43shEpdENosqwc8SjfuYRTPutvpbAkyeYa6k6QPR1WXWW2dFR
16059- zslYPxBtUuiTosvOKjCxg2uG/xd68ha/AJRYJMVriMd/vWAy3fKv3k9ZeBLTJsAMfDVtOp
16060- Q1sbLkUY4KyTeL0oGObzV1rJ8iyA3vJqfA9VolC4T1QI6q2BxPcNOX2r14fYet3a/kSI2+
16061- aSl7Guonc5V5E716gcuj7w87AXZqDcLDsAAADBAPgf/gfY1rN269TN2CpudEIM4T5c6vl2
16062- /6E1+49xkUDV6DDllQCM4ZJ7oTzu6hkWOYe9AAqgmkSYq0qGA2JT96Mh5qQSxj51p6z1CI
16063- udoPxMG7kgQQYcEFiAd7NZEPxGY34pwCG73m9DeJt5hIZR6YQBZVKJsFOrlXAni9ambb2c
16064- 9YbMSAyFazmpU2uu2X8YRUIjB2C0ggFDUDRilK/ssWxX+HiPU+2woaxemcuK0kWEC02wXo
16065- bEX7D3T3mJDvVj9wAAAA9lcGlseXNAY29tcG91bmQBAg==
16066- -----END OPENSSH PRIVATE KEY-----
16067 diff --git a/core/tests/ssh_key.pub b/core/tests/ssh_key.pub
16068deleted file mode 100644
16069index 600ab36..0000000
16070--- a/core/tests/ssh_key.pub
16071+++ /dev/null
16072 @@ -1 +0,0 @@
16073- ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD1bB0mz86HGEOhepIIkdzdjCtzd3YBPN+Lw5WaWSbeQtKUYg4F59mdZ/R7D6ZTy1YiDT5Tr+ySH20O9nyO/xX6OwWGU/bnt5agw2PoLysGhdU6vlpRey/KJug74b15y7Zb0ooNJeU5E4Du5yYrHaxSo10HofCGfCGILYurObu601Vz0UtSTICuFb01t2SWyZKhwM/OX9acTeXfECxpNBNHNZe1dOOK3bsyoiyHst32r6mofkNiVqbbXdJMT/RC/gZl2MtN0hGgdDxUtleyTymmRHFQJhTi00jJ1/DH9HEYbl/zBNu7LT3oKMl8NzEqxAs848/LOFK2/eGeEdYg/wgi89d8BTChMbqjlnsLPPSSX8Jc4BO8RK0lxyPpfXdiZoggNZKZzkmZ6UMYOPUZHngbcZuKbOsIb6dKuF9vB6c9J5/NyfE9J1E52jtJK3R+AxXF86L6tWuyvgpYxBSrt7UtiBxaKv3SDpbhXRLh1FEGcPQD0a4VFymo8+fP7ugAfe0= epilys@localhost
16074 diff --git a/core/tests/subscription.rs b/core/tests/subscription.rs
16075deleted file mode 100644
16076index 1f5468c..0000000
16077--- a/core/tests/subscription.rs
16078+++ /dev/null
16079 @@ -1,330 +0,0 @@
16080- /*
16081- * This file is part of mailpot
16082- *
16083- * Copyright 2020 - Manos Pitsidianakis
16084- *
16085- * This program is free software: you can redistribute it and/or modify
16086- * it under the terms of the GNU Affero General Public License as
16087- * published by the Free Software Foundation, either version 3 of the
16088- * License, or (at your option) any later version.
16089- *
16090- * This program is distributed in the hope that it will be useful,
16091- * but WITHOUT ANY WARRANTY; without even the implied warranty of
16092- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16093- * GNU Affero General Public License for more details.
16094- *
16095- * You should have received a copy of the GNU Affero General Public License
16096- * along with this program. If not, see <https://www.gnu.org/licenses/>.
16097- */
16098-
16099- use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
16100- use mailpot_tests::init_stderr_logging;
16101- use serde_json::json;
16102- use tempfile::TempDir;
16103-
16104- #[test]
16105- fn test_list_subscription() {
16106- init_stderr_logging();
16107-
16108- let tmp_dir = TempDir::new().unwrap();
16109-
16110- let db_path = tmp_dir.path().join("mpot.db");
16111- let config = Configuration {
16112- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
16113- db_path,
16114- data_path: tmp_dir.path().to_path_buf(),
16115- administrators: vec![],
16116- };
16117-
16118- let db = Connection::open_or_create_db(config).unwrap().trusted();
16119- assert!(db.lists().unwrap().is_empty());
16120- let foo_chat = db
16121- .create_list(MailingList {
16122- pk: 0,
16123- name: "foobar chat".into(),
16124- id: "foo-chat".into(),
16125- address: "foo-chat@example.com".into(),
16126- description: None,
16127- topics: vec![],
16128- archive_url: None,
16129- })
16130- .unwrap();
16131-
16132- assert_eq!(foo_chat.pk(), 1);
16133- let lists = db.lists().unwrap();
16134- assert_eq!(lists.len(), 1);
16135- assert_eq!(lists[0], foo_chat);
16136- let post_policy = db
16137- .set_list_post_policy(PostPolicy {
16138- pk: 0,
16139- list: foo_chat.pk(),
16140- announce_only: false,
16141- subscription_only: true,
16142- approval_needed: false,
16143- open: false,
16144- custom: false,
16145- })
16146- .unwrap();
16147-
16148- assert_eq!(post_policy.pk(), 1);
16149- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
16150- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
16151-
16152- let db = db.untrusted();
16153-
16154- let post_bytes = b"From: Name <user@example.com>
16155- To: <foo-chat@example.com>
16156- Subject: This is a post
16157- Date: Thu, 29 Oct 2020 13:58:16 +0000
16158- Message-ID: <abcdefgh@sator.example.com>
16159- Content-Language: en-US
16160- Content-Type: text/html
16161- Content-Transfer-Encoding: base64
16162- MIME-Version: 1.0
16163-
16164- PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
16165- eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
16166- Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
16167- eT48L2h0bWw+
16168- ";
16169- let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
16170- db.post(&envelope, post_bytes, /* dry_run */ false)
16171- .expect("Got unexpected error");
16172- let out = db.queue(Queue::Out).unwrap();
16173- assert_eq!(out.len(), 1);
16174- const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
16175- assert_eq!(
16176- out[0]
16177- .comment
16178- .as_ref()
16179- .and_then(|c| c.get(..COMMENT_PREFIX.len())),
16180- Some(COMMENT_PREFIX)
16181- );
16182-
16183- let subscribe_bytes = b"From: Name <user@example.com>
16184- To: <foo-chat+subscribe@example.com>
16185- Subject: subscribe
16186- Date: Thu, 29 Oct 2020 13:58:16 +0000
16187- Message-ID: <abcdefgh@sator.example.com>
16188- Content-Language: en-US
16189- Content-Type: text/html
16190- Content-Transfer-Encoding: base64
16191- MIME-Version: 1.0
16192-
16193- ";
16194- let envelope =
16195- melib::Envelope::from_bytes(subscribe_bytes, None).expect("Could not parse message");
16196- db.post(&envelope, subscribe_bytes, /* dry_run */ false)
16197- .unwrap();
16198- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
16199- assert_eq!(db.queue(Queue::Out).unwrap().len(), 2);
16200- let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
16201- db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
16202- assert_eq!(db.queue(Queue::Out).unwrap().len(), 2);
16203- assert_eq!(db.list_posts(foo_chat.pk(), None).unwrap().len(), 1);
16204- }
16205-
16206- #[test]
16207- fn test_post_rejection() {
16208- init_stderr_logging();
16209-
16210- const ANNOUNCE_ONLY_PREFIX: Option<&str> =
16211- Some("PostAction::Reject { reason: You are not allowed to post on this list.");
16212- const APPROVAL_ONLY_PREFIX: Option<&str> = Some(
16213- "PostAction::Defer { reason: Your posting has been deferred. Approval from the list's \
16214- moderators",
16215- );
16216-
16217- for (q, mut post_policy) in [
16218- (
16219- [(Queue::Out, ANNOUNCE_ONLY_PREFIX)].as_slice(),
16220- PostPolicy {
16221- pk: -1,
16222- list: -1,
16223- announce_only: true,
16224- subscription_only: false,
16225- approval_needed: false,
16226- open: false,
16227- custom: false,
16228- },
16229- ),
16230- (
16231- [(Queue::Out, APPROVAL_ONLY_PREFIX), (Queue::Deferred, None)].as_slice(),
16232- PostPolicy {
16233- pk: -1,
16234- list: -1,
16235- announce_only: false,
16236- subscription_only: false,
16237- approval_needed: true,
16238- open: false,
16239- custom: false,
16240- },
16241- ),
16242- ] {
16243- let tmp_dir = TempDir::new().unwrap();
16244-
16245- let db_path = tmp_dir.path().join("mpot.db");
16246- let config = Configuration {
16247- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
16248- db_path,
16249- data_path: tmp_dir.path().to_path_buf(),
16250- administrators: vec![],
16251- };
16252-
16253- let db = Connection::open_or_create_db(config).unwrap().trusted();
16254- assert!(db.lists().unwrap().is_empty());
16255- let foo_chat = db
16256- .create_list(MailingList {
16257- pk: 0,
16258- name: "foobar chat".into(),
16259- id: "foo-chat".into(),
16260- address: "foo-chat@example.com".into(),
16261- description: None,
16262- topics: vec![],
16263- archive_url: None,
16264- })
16265- .unwrap();
16266-
16267- assert_eq!(foo_chat.pk(), 1);
16268- let lists = db.lists().unwrap();
16269- assert_eq!(lists.len(), 1);
16270- assert_eq!(lists[0], foo_chat);
16271- post_policy.list = foo_chat.pk();
16272- let post_policy = db.set_list_post_policy(post_policy).unwrap();
16273-
16274- assert_eq!(post_policy.pk(), 1);
16275- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
16276- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
16277-
16278- let db = db.untrusted();
16279-
16280- let post_bytes = b"From: Name <user@example.com>
16281- To: <foo-chat@example.com>
16282- Subject: This is a post
16283- Date: Thu, 29 Oct 2020 13:58:16 +0000
16284- Message-ID: <abcdefgh@sator.example.com>
16285- Content-Language: en-US
16286- Content-Type: text/html
16287- Content-Transfer-Encoding: base64
16288- MIME-Version: 1.0
16289-
16290- PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
16291- eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
16292- Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
16293- eT48L2h0bWw+
16294- ";
16295- let envelope =
16296- melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
16297- db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
16298- for &(q, prefix) in q {
16299- let q = db.queue(q).unwrap();
16300- assert_eq!(q.len(), 1);
16301- if let Some(prefix) = prefix {
16302- assert_eq!(
16303- q[0].comment.as_ref().and_then(|c| c.get(..prefix.len())),
16304- Some(prefix)
16305- );
16306- }
16307- }
16308- }
16309- }
16310-
16311- #[test]
16312- fn test_post_filters() {
16313- init_stderr_logging();
16314- let tmp_dir = TempDir::new().unwrap();
16315-
16316- let mut post_policy = PostPolicy {
16317- pk: -1,
16318- list: -1,
16319- announce_only: false,
16320- subscription_only: false,
16321- approval_needed: false,
16322- open: true,
16323- custom: false,
16324- };
16325- let db_path = tmp_dir.path().join("mpot.db");
16326- let config = Configuration {
16327- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
16328- db_path,
16329- data_path: tmp_dir.path().to_path_buf(),
16330- administrators: vec![],
16331- };
16332-
16333- let db = Connection::open_or_create_db(config).unwrap().trusted();
16334- let foo_chat = db
16335- .create_list(MailingList {
16336- pk: 0,
16337- name: "foobar chat".into(),
16338- id: "foo-chat".into(),
16339- address: "foo-chat@example.com".into(),
16340- description: None,
16341- topics: vec![],
16342- archive_url: None,
16343- })
16344- .unwrap();
16345- post_policy.list = foo_chat.pk();
16346- db.add_subscription(
16347- foo_chat.pk(),
16348- ListSubscription {
16349- pk: -1,
16350- list: foo_chat.pk(),
16351- address: "user@example.com".into(),
16352- name: None,
16353- account: None,
16354- digest: false,
16355- enabled: true,
16356- verified: true,
16357- hide_address: false,
16358- receive_duplicates: true,
16359- receive_own_posts: true,
16360- receive_confirmation: false,
16361- },
16362- )
16363- .unwrap();
16364- db.set_list_post_policy(post_policy).unwrap();
16365-
16366- let post_bytes = b"From: Name <user@example.com>
16367- To: <foo-chat@example.com>
16368- Subject: This is a post
16369- Date: Thu, 29 Oct 2020 13:58:16 +0000
16370- Message-ID: <abcdefgh@sator.example.com>
16371- Content-Language: en-US
16372- Content-Type: text/html
16373- Content-Transfer-Encoding: base64
16374- MIME-Version: 1.0
16375-
16376- PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
16377- eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
16378- Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
16379- eT48L2h0bWw+
16380- ";
16381- let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
16382- db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
16383- let q = db.queue(Queue::Out).unwrap();
16384- assert_eq!(&q[0].subject, "[foo-chat] This is a post");
16385-
16386- db.delete_from_queue(Queue::Out, vec![]).unwrap();
16387- {
16388- let mut stmt = db
16389- .connection
16390- .prepare(
16391- "INSERT INTO list_settings_json(name, list, value) \
16392- VALUES('AddSubjectTagPrefixSettings', ?, ?) RETURNING *;",
16393- )
16394- .unwrap();
16395- stmt.query_row(
16396- rusqlite::params![
16397- &foo_chat.pk(),
16398- &json!({
16399- "enabled": false
16400- }),
16401- ],
16402- |_| Ok(()),
16403- )
16404- .unwrap();
16405- }
16406- db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
16407- let q = db.queue(Queue::Out).unwrap();
16408- assert_eq!(&q[0].subject, "This is a post");
16409- }
16410 diff --git a/core/tests/template_replies.rs b/core/tests/template_replies.rs
16411deleted file mode 100644
16412index 8648b2e..0000000
16413--- a/core/tests/template_replies.rs
16414+++ /dev/null
16415 @@ -1,236 +0,0 @@
16416- /*
16417- * This file is part of mailpot
16418- *
16419- * Copyright 2020 - Manos Pitsidianakis
16420- *
16421- * This program is free software: you can redistribute it and/or modify
16422- * it under the terms of the GNU Affero General Public License as
16423- * published by the Free Software Foundation, either version 3 of the
16424- * License, or (at your option) any later version.
16425- *
16426- * This program is distributed in the hope that it will be useful,
16427- * but WITHOUT ANY WARRANTY; without even the implied warranty of
16428- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16429- * GNU Affero General Public License for more details.
16430- *
16431- * You should have received a copy of the GNU Affero General Public License
16432- * along with this program. If not, see <https://www.gnu.org/licenses/>.
16433- */
16434-
16435- use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail, Template};
16436- use mailpot_tests::init_stderr_logging;
16437- use tempfile::TempDir;
16438-
16439- #[test]
16440- fn test_template_replies() {
16441- init_stderr_logging();
16442-
16443- const SUB_BYTES: &[u8] = b"From: Name <user@example.com>
16444- To: <foo-chat+subscribe@example.com>
16445- Subject: subscribe
16446- Date: Thu, 29 Oct 2020 13:58:16 +0000
16447- Message-ID: <abcdefgh@sator.example.com>
16448- Content-Language: en-US
16449- Content-Type: text/html
16450- Content-Transfer-Encoding: base64
16451- MIME-Version: 1.0
16452-
16453- ";
16454- const UNSUB_BYTES: &[u8] = b"From: Name <user@example.com>
16455- To: <foo-chat+request@example.com>
16456- Subject: unsubscribe
16457- Date: Thu, 29 Oct 2020 13:58:17 +0000
16458- Message-ID: <abcdefgh@sator.example.com>
16459- Content-Language: en-US
16460- Content-Type: text/html
16461- Content-Transfer-Encoding: base64
16462- MIME-Version: 1.0
16463-
16464- ";
16465-
16466- let tmp_dir = TempDir::new().unwrap();
16467-
16468- let db_path = tmp_dir.path().join("mpot.db");
16469- let config = Configuration {
16470- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
16471- db_path,
16472- data_path: tmp_dir.path().to_path_buf(),
16473- administrators: vec![],
16474- };
16475-
16476- let mut db = Connection::open_or_create_db(config).unwrap().trusted();
16477- assert!(db.lists().unwrap().is_empty());
16478- let foo_chat = db
16479- .create_list(MailingList {
16480- pk: 0,
16481- name: "foobar chat".into(),
16482- id: "foo-chat".into(),
16483- address: "foo-chat@example.com".into(),
16484- description: None,
16485- topics: vec![],
16486- archive_url: None,
16487- })
16488- .unwrap();
16489-
16490- assert_eq!(foo_chat.pk(), 1);
16491- let lists = db.lists().unwrap();
16492- assert_eq!(lists.len(), 1);
16493- assert_eq!(lists[0], foo_chat);
16494- let post_policy = db
16495- .set_list_post_policy(PostPolicy {
16496- pk: 0,
16497- list: foo_chat.pk(),
16498- announce_only: false,
16499- subscription_only: true,
16500- approval_needed: false,
16501- open: false,
16502- custom: false,
16503- })
16504- .unwrap();
16505-
16506- assert_eq!(post_policy.pk(), 1);
16507- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
16508- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
16509-
16510- let _templ_gen = db
16511- .add_template(Template {
16512- pk: -1,
16513- name: Template::SUBSCRIPTION_CONFIRMATION.into(),
16514- list: None,
16515- subject: Some("You have subscribed to a list".into()),
16516- headers_json: None,
16517- body: "You have subscribed to a list".into(),
16518- })
16519- .unwrap();
16520- /* create custom subscribe confirm template, and check that it is used in
16521- * action */
16522- let _templ = db
16523- .add_template(Template {
16524- pk: -1,
16525- name: Template::SUBSCRIPTION_CONFIRMATION.into(),
16526- list: Some(foo_chat.pk()),
16527- subject: Some("You have subscribed to {{ list.name }}".into()),
16528- headers_json: None,
16529- body: "You have subscribed to {{ list.name }}".into(),
16530- })
16531- .unwrap();
16532- let _all = db.fetch_templates().unwrap();
16533- assert_eq!(&_all[0], &_templ_gen);
16534- assert_eq!(&_all[1], &_templ);
16535- assert_eq!(_all.len(), 2);
16536-
16537- let sub_fn = |db: &mut Connection| {
16538- let subenvelope =
16539- melib::Envelope::from_bytes(SUB_BYTES, None).expect("Could not parse message");
16540- db.post(&subenvelope, SUB_BYTES, /* dry_run */ false)
16541- .unwrap();
16542- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
16543- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
16544- };
16545- let unsub_fn = |db: &mut Connection| {
16546- let envelope =
16547- melib::Envelope::from_bytes(UNSUB_BYTES, None).expect("Could not parse message");
16548- db.post(&envelope, UNSUB_BYTES, /* dry_run */ false)
16549- .unwrap();
16550- assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
16551- assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
16552- };
16553-
16554- /* subscribe first */
16555-
16556- sub_fn(&mut db);
16557-
16558- let out_queue = db.queue(Queue::Out).unwrap();
16559- assert_eq!(out_queue.len(), 1);
16560- let out = &out_queue[0];
16561- let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
16562-
16563- assert_eq!(
16564- &out_env.from()[0].get_email(),
16565- "foo-chat+request@example.com",
16566- );
16567- assert_eq!(
16568- (
16569- out_env.to()[0].get_display_name().as_deref(),
16570- out_env.to()[0].get_email().as_str()
16571- ),
16572- (Some("Name"), "user@example.com"),
16573- );
16574- assert_eq!(
16575- &out.subject,
16576- &format!("You have subscribed to {}", foo_chat.name)
16577- );
16578-
16579- /* then unsubscribe, remove custom template and subscribe again */
16580-
16581- unsub_fn(&mut db);
16582-
16583- let out_queue = db.queue(Queue::Out).unwrap();
16584- assert_eq!(out_queue.len(), 2);
16585-
16586- let mut _templ = _templ.into_inner();
16587- let _templ2 = db
16588- .remove_template(Template::SUBSCRIPTION_CONFIRMATION, Some(foo_chat.pk()))
16589- .unwrap();
16590- _templ.pk = _templ2.pk;
16591- assert_eq!(_templ, _templ2);
16592-
16593- /* now the first inserted template should be used: */
16594-
16595- sub_fn(&mut db);
16596-
16597- let out_queue = db.queue(Queue::Out).unwrap();
16598-
16599- assert_eq!(out_queue.len(), 3);
16600- let out = &out_queue[2];
16601- let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
16602-
16603- assert_eq!(
16604- &out_env.from()[0].get_email(),
16605- "foo-chat+request@example.com",
16606- );
16607- assert_eq!(
16608- (
16609- out_env.to()[0].get_display_name().as_deref(),
16610- out_env.to()[0].get_email().as_str()
16611- ),
16612- (Some("Name"), "user@example.com"),
16613- );
16614- assert_eq!(&out.subject, "You have subscribed to a list");
16615-
16616- unsub_fn(&mut db);
16617- let mut _templ_gen_2 = db
16618- .remove_template(Template::SUBSCRIPTION_CONFIRMATION, None)
16619- .unwrap();
16620- _templ_gen_2.pk = _templ_gen.pk;
16621- assert_eq!(_templ_gen_2, _templ_gen.into_inner());
16622-
16623- /* now this template should be used: */
16624-
16625- sub_fn(&mut db);
16626-
16627- let out_queue = db.queue(Queue::Out).unwrap();
16628-
16629- assert_eq!(out_queue.len(), 5);
16630- let out = &out_queue[4];
16631- let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
16632-
16633- assert_eq!(
16634- &out_env.from()[0].get_email(),
16635- "foo-chat+request@example.com",
16636- );
16637- assert_eq!(
16638- (
16639- out_env.to()[0].get_display_name().as_deref(),
16640- out_env.to()[0].get_email().as_str()
16641- ),
16642- (Some("Name"), "user@example.com"),
16643- );
16644- assert_eq!(
16645- &out.subject,
16646- &format!(
16647- "[{}] You have successfully subscribed to {}.",
16648- foo_chat.id, foo_chat.name
16649- )
16650- );
16651- }
16652 diff --git a/core/tests/test_sample_longmessage.eml b/core/tests/test_sample_longmessage.eml
16653deleted file mode 100644
16654index a41ff28..0000000
16655--- a/core/tests/test_sample_longmessage.eml
16656+++ /dev/null
16657 @@ -1,25 +0,0 @@
16658- Return-Path: <paaoejunp@example.com>
16659- Delivered-To: john@example.com
16660- Received: from violet.example.com
16661- by violet.example.com with LMTP
16662- id qBHcI7LKml9FxzIAYrQLqw
16663- (envelope-from <paaoejunp@example.com>)
16664- for <john@example.com>; Thu, 29 Oct 2020 13:59:14 +0000
16665- Return-path: <paaoejunp@example.com>
16666- Envelope-to: john@example.com
16667- Delivery-date: Thu, 29 Oct 2020 13:59:14 +0000
16668- From: Cardholder Name <paaoejunp@example.com>
16669- To: <foo-chat@example.com>
16670- Subject: thankful that I had the chance to written report, that I could learn
16671- and let alone the chance $4454.32
16672- Date: Thu, 29 Oct 2020 13:58:16 +0000
16673- Message-ID: <abcdefgh@sator.example.com>
16674- Content-Language: en-US
16675- Content-Type: text/html
16676- Content-Transfer-Encoding: base64
16677- MIME-Version: 1.0
16678-
16679- PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
16680- eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
16681- Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
16682- eT48L2h0bWw+
16683 diff --git a/core/tools/generate_configuration_json_schema.py b/core/tools/generate_configuration_json_schema.py
16684deleted file mode 100755
16685index e12fae1..0000000
16686--- a/core/tools/generate_configuration_json_schema.py
16687+++ /dev/null
16688 @@ -1,52 +0,0 @@
16689- #!/usr/bin/env python3
16690- """
16691- Example taken from https://jcristharif.com/msgspec/jsonschema.html
16692- """
16693- import msgspec
16694- from msgspec import Struct, Meta
16695- from typing import Annotated, Optional
16696-
16697- Template = Annotated[
16698- str,
16699- Meta(
16700- pattern=".+[{]msg-id[}].*",
16701- description="""Template for \
16702- `Archived-At` header value, as described in RFC 5064 "The Archived-At \
16703- Message Header Field". The template receives only one string variable \
16704- with the value of the mailing list post `Message-ID` header.
16705-
16706- For example, if:
16707-
16708- - the template is `http://www.example.com/mid/{msg-id}`
16709- - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`
16710-
16711- The full header will be generated as:
16712-
16713- `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>
16714-
16715- Note: Surrounding carets in the `Message-ID` value are not required. If \
16716- you wish to preserve them in the URL, set option `preserve-carets` to \
16717- true.""",
16718- title="Jinja template for header value",
16719- examples=[
16720- "https://www.example.com/{msg-id}",
16721- "https://www.example.com/{msg-id}.html",
16722- ],
16723- ),
16724- ]
16725-
16726- PreserveCarets = Annotated[
16727- bool, Meta(title="Preserve carets of `Message-ID` in generated value")
16728- ]
16729-
16730-
16731- class ArchivedAtLinkSettings(Struct):
16732- """Settings for ArchivedAtLink message filter"""
16733-
16734- template: Template
16735- preserve_carets: PreserveCarets = False
16736-
16737-
16738- schema = {"$schema": "http://json-schema.org/draft-07/schema"}
16739- schema.update(msgspec.json.schema(ArchivedAtLinkSettings))
16740- print(msgspec.json.format(msgspec.json.encode(schema)).decode("utf-8"))
16741 diff --git a/mailpot-archives/Cargo.toml b/mailpot-archives/Cargo.toml
16742new file mode 100644
16743index 0000000..18f4288
16744--- /dev/null
16745+++ b/mailpot-archives/Cargo.toml
16746 @@ -0,0 +1,25 @@
16747+ [package]
16748+ name = "mailpot-archives"
16749+ version = "0.1.1"
16750+ authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
16751+ edition = "2021"
16752+ license = "LICENSE"
16753+ readme = "README.md"
16754+ description = "mailing list manager"
16755+ repository = "https://github.com/meli/mailpot"
16756+ keywords = ["mail", "mailing-lists"]
16757+ categories = ["email"]
16758+ default-run = "mpot-archives"
16759+
16760+ [[bin]]
16761+ name = "mpot-archives"
16762+ path = "src/main.rs"
16763+
16764+ [dependencies]
16765+ chrono = { version = "^0.4" }
16766+ lazy_static = "^1.4"
16767+ mailpot = { version = "^0.1", path = "../mailpot" }
16768+ minijinja = { version = "0.31.0", features = ["source", ] }
16769+ percent-encoding = { version = "^2.1", optional = true }
16770+ serde = { version = "^1", features = ["derive", ] }
16771+ serde_json = "^1"
16772 diff --git a/mailpot-archives/README.md b/mailpot-archives/README.md
16773new file mode 100644
16774index 0000000..623e387
16775--- /dev/null
16776+++ b/mailpot-archives/README.md
16777 @@ -0,0 +1,12 @@
16778+ # mailpot REST http server
16779+
16780+ ```shell
16781+ cargo run --bin mpot-archives
16782+ ```
16783+
16784+ ## generate static files
16785+
16786+ ```shell
16787+ # mpot-gen CONF_FILE OUTPUT_DIR OPTIONAL_ROOT_URL_PREFIX
16788+ cargo run --bin mpot-gen -- ../conf.toml ./out/ "/mailpot"
16789+ ```
16790 diff --git a/mailpot-archives/rustfmt.toml b/mailpot-archives/rustfmt.toml
16791new file mode 120000
16792index 0000000..39f97b0
16793--- /dev/null
16794+++ b/mailpot-archives/rustfmt.toml
16795 @@ -0,0 +1 @@
16796+ ../rustfmt.toml
16797\ No newline at end of file
16798 diff --git a/mailpot-archives/src/cal.rs b/mailpot-archives/src/cal.rs
16799new file mode 100644
16800index 0000000..3725d8a
16801--- /dev/null
16802+++ b/mailpot-archives/src/cal.rs
16803 @@ -0,0 +1,244 @@
16804+ // MIT License
16805+ //
16806+ // Copyright (c) 2021 sadnessOjisan
16807+ //
16808+ // Permission is hereby granted, free of charge, to any person obtaining a copy
16809+ // of this software and associated documentation files (the "Software"), to deal
16810+ // in the Software without restriction, including without limitation the rights
16811+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16812+ // copies of the Software, and to permit persons to whom the Software is
16813+ // furnished to do so, subject to the following conditions:
16814+ //
16815+ // The above copyright notice and this permission notice shall be included in
16816+ // all copies or substantial portions of the Software.
16817+ //
16818+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16819+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16820+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16821+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
16822+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16823+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
16824+ // SOFTWARE.
16825+
16826+ use chrono::*;
16827+
16828+ #[allow(dead_code)]
16829+ /// Generate a calendar view of the given date's month.
16830+ ///
16831+ /// Each vector element is an array of seven numbers representing weeks
16832+ /// (starting on Sundays), and each value is the numeric date.
16833+ /// A value of zero means a date that not exists in the current month.
16834+ ///
16835+ /// # Examples
16836+ /// ```
16837+ /// use chrono::*;
16838+ /// use mailpot_archives::cal::calendarize;
16839+ ///
16840+ /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
16841+ /// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
16842+ /// println!("{:?}", calendarize(date));
16843+ /// // [0, 0, 0, 0, 0, 1, 2],
16844+ /// // [3, 4, 5, 6, 7, 8, 9],
16845+ /// // [10, 11, 12, 13, 14, 15, 16],
16846+ /// // [17, 18, 19, 20, 21, 22, 23],
16847+ /// // [24, 25, 26, 27, 28, 29, 30],
16848+ /// // [31, 0, 0, 0, 0, 0, 0]
16849+ /// ```
16850+ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
16851+ calendarize_with_offset(date, 0)
16852+ }
16853+
16854+ /// Generate a calendar view of the given date's month and offset.
16855+ ///
16856+ /// Each vector element is an array of seven numbers representing weeks
16857+ /// (starting on Sundays), and each value is the numeric date.
16858+ /// A value of zero means a date that not exists in the current month.
16859+ ///
16860+ /// Offset means the number of days from sunday.
16861+ /// For example, 1 means monday, 6 means saturday.
16862+ ///
16863+ /// # Examples
16864+ /// ```
16865+ /// use chrono::*;
16866+ /// use mailpot_archives::cal::calendarize_with_offset;
16867+ ///
16868+ /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
16869+ /// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
16870+ /// println!("{:?}", calendarize_with_offset(date, 1));
16871+ /// // [0, 0, 0, 0, 1, 2, 3],
16872+ /// // [4, 5, 6, 7, 8, 9, 10],
16873+ /// // [11, 12, 13, 14, 15, 16, 17],
16874+ /// // [18, 19, 20, 21, 22, 23, 24],
16875+ /// // [25, 26, 27, 28, 29, 30, 0],
16876+ /// ```
16877+ pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
16878+ let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
16879+ let year = date.year();
16880+ let month = date.month();
16881+ let num_days_from_sunday = NaiveDate::from_ymd_opt(year, month, 1)
16882+ .unwrap()
16883+ .weekday()
16884+ .num_days_from_sunday();
16885+ let mut first_date_day;
16886+ if num_days_from_sunday < offset {
16887+ first_date_day = num_days_from_sunday + (7 - offset);
16888+ } else {
16889+ first_date_day = num_days_from_sunday - offset;
16890+ }
16891+ let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
16892+ .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
16893+ .pred_opt()
16894+ .unwrap()
16895+ .day();
16896+
16897+ let mut date: u32 = 0;
16898+ while date < end_date {
16899+ let mut week: [u32; 7] = [0; 7];
16900+ for day in first_date_day..7 {
16901+ date += 1;
16902+ week[day as usize] = date;
16903+ if date >= end_date {
16904+ break;
16905+ }
16906+ }
16907+ first_date_day = 0;
16908+
16909+ monthly_calendar.push(week);
16910+ }
16911+
16912+ monthly_calendar
16913+ }
16914+
16915+ #[test]
16916+ fn january() {
16917+ let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
16918+ let actual = calendarize(date);
16919+ assert_eq!(
16920+ vec![
16921+ [0, 0, 0, 0, 0, 1, 2],
16922+ [3, 4, 5, 6, 7, 8, 9],
16923+ [10, 11, 12, 13, 14, 15, 16],
16924+ [17, 18, 19, 20, 21, 22, 23],
16925+ [24, 25, 26, 27, 28, 29, 30],
16926+ [31, 0, 0, 0, 0, 0, 0]
16927+ ],
16928+ actual
16929+ );
16930+ }
16931+
16932+ #[test]
16933+ // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
16934+ fn with_offset_from_sunday() {
16935+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
16936+ let actual = calendarize_with_offset(date, 0);
16937+ assert_eq!(
16938+ vec![
16939+ [0, 0, 0, 0, 0, 1, 2],
16940+ [3, 4, 5, 6, 7, 8, 9],
16941+ [10, 11, 12, 13, 14, 15, 16],
16942+ [17, 18, 19, 20, 21, 22, 23],
16943+ [24, 25, 26, 27, 28, 29, 30],
16944+ ],
16945+ actual
16946+ );
16947+ }
16948+
16949+ #[test]
16950+ // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
16951+ fn with_offset_from_monday() {
16952+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
16953+ let actual = calendarize_with_offset(date, 1);
16954+ assert_eq!(
16955+ vec![
16956+ [0, 0, 0, 0, 1, 2, 3],
16957+ [4, 5, 6, 7, 8, 9, 10],
16958+ [11, 12, 13, 14, 15, 16, 17],
16959+ [18, 19, 20, 21, 22, 23, 24],
16960+ [25, 26, 27, 28, 29, 30, 0],
16961+ ],
16962+ actual
16963+ );
16964+ }
16965+
16966+ #[test]
16967+ // Week = [Sat, Sun, Mon, Tue, Wed, Thu, Fri]
16968+ fn with_offset_from_saturday() {
16969+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
16970+ let actual = calendarize_with_offset(date, 6);
16971+ assert_eq!(
16972+ vec![
16973+ [0, 0, 0, 0, 0, 0, 1],
16974+ [2, 3, 4, 5, 6, 7, 8],
16975+ [9, 10, 11, 12, 13, 14, 15],
16976+ [16, 17, 18, 19, 20, 21, 22],
16977+ [23, 24, 25, 26, 27, 28, 29],
16978+ [30, 0, 0, 0, 0, 0, 0]
16979+ ],
16980+ actual
16981+ );
16982+ }
16983+
16984+ #[test]
16985+ // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
16986+ fn with_offset_from_sunday_with7() {
16987+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
16988+ let actual = calendarize_with_offset(date, 7);
16989+ assert_eq!(
16990+ vec![
16991+ [0, 0, 0, 0, 0, 1, 2],
16992+ [3, 4, 5, 6, 7, 8, 9],
16993+ [10, 11, 12, 13, 14, 15, 16],
16994+ [17, 18, 19, 20, 21, 22, 23],
16995+ [24, 25, 26, 27, 28, 29, 30],
16996+ ],
16997+ actual
16998+ );
16999+ }
17000+
17001+ #[test]
17002+ fn april() {
17003+ let date = NaiveDate::parse_from_str("2021-04-02", "%Y-%m-%d").unwrap();
17004+ let actual = calendarize(date);
17005+ assert_eq!(
17006+ vec![
17007+ [0, 0, 0, 0, 1, 2, 3],
17008+ [4, 5, 6, 7, 8, 9, 10],
17009+ [11, 12, 13, 14, 15, 16, 17],
17010+ [18, 19, 20, 21, 22, 23, 24],
17011+ [25, 26, 27, 28, 29, 30, 0]
17012+ ],
17013+ actual
17014+ );
17015+ }
17016+
17017+ #[test]
17018+ fn uruudoshi() {
17019+ let date = NaiveDate::parse_from_str("2020-02-02", "%Y-%m-%d").unwrap();
17020+ let actual = calendarize(date);
17021+ assert_eq!(
17022+ vec![
17023+ [0, 0, 0, 0, 0, 0, 1],
17024+ [2, 3, 4, 5, 6, 7, 8],
17025+ [9, 10, 11, 12, 13, 14, 15],
17026+ [16, 17, 18, 19, 20, 21, 22],
17027+ [23, 24, 25, 26, 27, 28, 29]
17028+ ],
17029+ actual
17030+ );
17031+ }
17032+
17033+ #[test]
17034+ fn uruwanaidoshi() {
17035+ let date = NaiveDate::parse_from_str("2021-02-02", "%Y-%m-%d").unwrap();
17036+ let actual = calendarize(date);
17037+ assert_eq!(
17038+ vec![
17039+ [0, 1, 2, 3, 4, 5, 6],
17040+ [7, 8, 9, 10, 11, 12, 13],
17041+ [14, 15, 16, 17, 18, 19, 20],
17042+ [21, 22, 23, 24, 25, 26, 27],
17043+ [28, 0, 0, 0, 0, 0, 0]
17044+ ],
17045+ actual
17046+ );
17047+ }
17048 diff --git a/mailpot-archives/src/gen.rs b/mailpot-archives/src/gen.rs
17049new file mode 100644
17050index 0000000..9f9025a
17051--- /dev/null
17052+++ b/mailpot-archives/src/gen.rs
17053 @@ -0,0 +1,259 @@
17054+ /*
17055+ * This file is part of mailpot
17056+ *
17057+ * Copyright 2020 - Manos Pitsidianakis
17058+ *
17059+ * This program is free software: you can redistribute it and/or modify
17060+ * it under the terms of the GNU Affero General Public License as
17061+ * published by the Free Software Foundation, either version 3 of the
17062+ * License, or (at your option) any later version.
17063+ *
17064+ * This program is distributed in the hope that it will be useful,
17065+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
17066+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17067+ * GNU Affero General Public License for more details.
17068+ *
17069+ * You should have received a copy of the GNU Affero General Public License
17070+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
17071+ */
17072+
17073+ use std::{fs::OpenOptions, io::Write};
17074+
17075+ use mailpot::*;
17076+ use mailpot_archives::utils::*;
17077+ use minijinja::value::Value;
17078+
17079+ fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
17080+ let args = std::env::args().collect::<Vec<_>>();
17081+ let Some(config_path) = args
17082+ .get(1) else {
17083+ return Err("Expected configuration file path as first argument.".into());
17084+ };
17085+ let Some(output_path) = args
17086+ .get(2) else {
17087+ return Err("Expected output dir path as second argument.".into());
17088+ };
17089+ let root_url_prefix = args.get(3).cloned().unwrap_or_default();
17090+
17091+ let output_path = std::path::Path::new(&output_path);
17092+ if output_path.exists() && !output_path.is_dir() {
17093+ return Err("Output path is not a directory.".into());
17094+ }
17095+
17096+ std::fs::create_dir_all(&output_path.join("lists"))?;
17097+ std::fs::create_dir_all(&output_path.join("list"))?;
17098+ let conf = Configuration::from_file(config_path)
17099+ .map_err(|err| format!("Could not load config {config_path}: {err}"))?;
17100+
17101+ let db = Connection::open_db(conf).map_err(|err| format!("Couldn't open db: {err}"))?;
17102+ let lists_values = db.lists()?;
17103+ {
17104+ //index.html
17105+
17106+ let lists = lists_values
17107+ .iter()
17108+ .map(|list| {
17109+ let months = db.months(list.pk).unwrap();
17110+ let posts = db.list_posts(list.pk, None).unwrap();
17111+ minijinja::context! {
17112+ title => &list.name,
17113+ posts => &posts,
17114+ months => &months,
17115+ body => &list.description.as_deref().unwrap_or_default(),
17116+ root_prefix => &root_url_prefix,
17117+ list => Value::from_object(MailingList::from(list.clone())),
17118+ }
17119+ })
17120+ .collect::<Vec<_>>();
17121+ let mut file = OpenOptions::new()
17122+ .write(true)
17123+ .create(true)
17124+ .truncate(true)
17125+ .open(&output_path.join("index.html"))?;
17126+ let crumbs = vec![Crumb {
17127+ label: "Lists".into(),
17128+ url: format!("{root_url_prefix}/").into(),
17129+ }];
17130+
17131+ let context = minijinja::context! {
17132+ title => "mailing list archive",
17133+ description => "",
17134+ lists => &lists,
17135+ root_prefix => &root_url_prefix,
17136+ crumbs => crumbs,
17137+ };
17138+ file.write_all(
17139+ TEMPLATES
17140+ .get_template("lists.html")?
17141+ .render(context)?
17142+ .as_bytes(),
17143+ )?;
17144+ }
17145+
17146+ let mut lists_path = output_path.to_path_buf();
17147+
17148+ for list in &lists_values {
17149+ lists_path.push("lists");
17150+ lists_path.push(list.pk.to_string());
17151+ std::fs::create_dir_all(&lists_path)?;
17152+ lists_path.push("index.html");
17153+
17154+ let list = db.list(list.pk)?.unwrap();
17155+ let post_policy = db.list_post_policy(list.pk)?;
17156+ let months = db.months(list.pk)?;
17157+ let posts = db.list_posts(list.pk, None)?;
17158+ let mut hist = months
17159+ .iter()
17160+ .map(|m| (m.to_string(), [0usize; 31]))
17161+ .collect::<std::collections::HashMap<String, [usize; 31]>>();
17162+ let posts_ctx = posts
17163+ .iter()
17164+ .map(|post| {
17165+ //2019-07-14T14:21:02
17166+ if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
17167+ hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
17168+ }
17169+ let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
17170+ .expect("Could not parse mail");
17171+ let mut msg_id = &post.message_id[1..];
17172+ msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
17173+ let subject = envelope.subject();
17174+ let mut subject_ref = subject.trim();
17175+ if subject_ref.starts_with('[')
17176+ && subject_ref[1..].starts_with(&list.id)
17177+ && subject_ref[1 + list.id.len()..].starts_with(']')
17178+ {
17179+ subject_ref = subject_ref[2 + list.id.len()..].trim();
17180+ }
17181+ minijinja::context! {
17182+ pk => post.pk,
17183+ list => post.list,
17184+ subject => subject_ref,
17185+ address=> post.address,
17186+ message_id => msg_id,
17187+ message => post.message,
17188+ timestamp => post.timestamp,
17189+ datetime => post.datetime,
17190+ root_prefix => &root_url_prefix,
17191+ }
17192+ })
17193+ .collect::<Vec<_>>();
17194+ let crumbs = vec![
17195+ Crumb {
17196+ label: "Lists".into(),
17197+ url: format!("{root_url_prefix}/").into(),
17198+ },
17199+ Crumb {
17200+ label: list.name.clone().into(),
17201+ url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
17202+ },
17203+ ];
17204+ let context = minijinja::context! {
17205+ title=> &list.name,
17206+ description=> &list.description,
17207+ post_policy=> &post_policy,
17208+ preamble => true,
17209+ months=> &months,
17210+ hists => &hist,
17211+ posts=> posts_ctx,
17212+ body=>&list.description.clone().unwrap_or_default(),
17213+ root_prefix => &root_url_prefix,
17214+ list => Value::from_object(MailingList::from(list.clone())),
17215+ crumbs => crumbs,
17216+ };
17217+ let mut file = OpenOptions::new()
17218+ .read(true)
17219+ .write(true)
17220+ .create(true)
17221+ .truncate(true)
17222+ .open(&lists_path)
17223+ .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
17224+ file.write_all(
17225+ TEMPLATES
17226+ .get_template("list.html")?
17227+ .render(context)?
17228+ .as_bytes(),
17229+ )?;
17230+ lists_path.pop();
17231+ lists_path.pop();
17232+ lists_path.pop();
17233+ lists_path.push("list");
17234+ lists_path.push(list.pk.to_string());
17235+ std::fs::create_dir_all(&lists_path)?;
17236+
17237+ for post in posts {
17238+ let mut msg_id = &post.message_id[1..];
17239+ msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
17240+ lists_path.push(format!("{msg_id}.html"));
17241+ let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
17242+ .map_err(|err| format!("Could not parse mail {}: {err}", post.message_id))?;
17243+ let body = envelope.body_bytes(post.message.as_slice());
17244+ let body_text = body.text();
17245+ let subject = envelope.subject();
17246+ let mut subject_ref = subject.trim();
17247+ if subject_ref.starts_with('[')
17248+ && subject_ref[1..].starts_with(&list.id)
17249+ && subject_ref[1 + list.id.len()..].starts_with(']')
17250+ {
17251+ subject_ref = subject_ref[2 + list.id.len()..].trim();
17252+ }
17253+ let mut message_id = &post.message_id[1..];
17254+ message_id = &message_id[..message_id.len().saturating_sub(1)];
17255+ let crumbs = vec![
17256+ Crumb {
17257+ label: "Lists".into(),
17258+ url: format!("{root_url_prefix}/").into(),
17259+ },
17260+ Crumb {
17261+ label: list.name.clone().into(),
17262+ url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
17263+ },
17264+ Crumb {
17265+ label: subject_ref.to_string().into(),
17266+ url: format!("{root_url_prefix}/lists/{}/{message_id}.html/", list.pk).into(),
17267+ },
17268+ ];
17269+ let context = minijinja::context! {
17270+ title => &list.name,
17271+ list => &list,
17272+ post => &post,
17273+ posts => &posts_ctx,
17274+ body => &body_text,
17275+ from => &envelope.field_from_to_string(),
17276+ date => &envelope.date_as_str(),
17277+ to => &envelope.field_to_to_string(),
17278+ subject => &envelope.subject(),
17279+ trimmed_subject => subject_ref,
17280+ in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
17281+ references => &envelope .references() .into_iter() .map(|m| m.to_string().as_str().strip_carets().to_string()) .collect::<Vec<String>>(),
17282+ root_prefix => &root_url_prefix,
17283+ crumbs => crumbs,
17284+ };
17285+ let mut file = OpenOptions::new()
17286+ .read(true)
17287+ .write(true)
17288+ .create(true)
17289+ .truncate(true)
17290+ .open(&lists_path)
17291+ .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
17292+ file.write_all(
17293+ TEMPLATES
17294+ .get_template("post.html")?
17295+ .render(context)?
17296+ .as_bytes(),
17297+ )?;
17298+ lists_path.pop();
17299+ }
17300+ lists_path.pop();
17301+ lists_path.pop();
17302+ }
17303+ Ok(())
17304+ }
17305+
17306+ fn main() -> std::result::Result<(), i64> {
17307+ if let Err(err) = run_app() {
17308+ eprintln!("{err}");
17309+ return Err(-1);
17310+ }
17311+ Ok(())
17312+ }
17313 diff --git a/mailpot-archives/src/lib.rs b/mailpot-archives/src/lib.rs
17314new file mode 100644
17315index 0000000..bf855fd
17316--- /dev/null
17317+++ b/mailpot-archives/src/lib.rs
17318 @@ -0,0 +1,21 @@
17319+ /*
17320+ * This file is part of mailpot
17321+ *
17322+ * Copyright 2020 - Manos Pitsidianakis
17323+ *
17324+ * This program is free software: you can redistribute it and/or modify
17325+ * it under the terms of the GNU Affero General Public License as
17326+ * published by the Free Software Foundation, either version 3 of the
17327+ * License, or (at your option) any later version.
17328+ *
17329+ * This program is distributed in the hope that it will be useful,
17330+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
17331+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17332+ * GNU Affero General Public License for more details.
17333+ *
17334+ * You should have received a copy of the GNU Affero General Public License
17335+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
17336+ */
17337+
17338+ pub mod cal;
17339+ pub mod utils;
17340 diff --git a/mailpot-archives/src/main.rs b/mailpot-archives/src/main.rs
17341new file mode 100644
17342index 0000000..e6ae3cc
17343--- /dev/null
17344+++ b/mailpot-archives/src/main.rs
17345 @@ -0,0 +1,257 @@
17346+ /*
17347+ * This file is part of mailpot
17348+ *
17349+ * Copyright 2020 - Manos Pitsidianakis
17350+ *
17351+ * This program is free software: you can redistribute it and/or modify
17352+ * it under the terms of the GNU Affero General Public License as
17353+ * published by the Free Software Foundation, either version 3 of the
17354+ * License, or (at your option) any later version.
17355+ *
17356+ * This program is distributed in the hope that it will be useful,
17357+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
17358+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17359+ * GNU Affero General Public License for more details.
17360+ *
17361+ * You should have received a copy of the GNU Affero General Public License
17362+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
17363+ */
17364+
17365+ use std::{fs::OpenOptions, io::Write};
17366+
17367+ use mailpot::*;
17368+ use mailpot_archives::utils::*;
17369+ use minijinja::value::Value;
17370+
17371+ fn run_app() -> std::result::Result<(), Box<dyn std::error::Error>> {
17372+ let args = std::env::args().collect::<Vec<_>>();
17373+ let Some(config_path) = args.get(1) else {
17374+ return Err("Expected configuration file path as first argument.".into());
17375+ };
17376+ let Some(output_path) = args.get(2) else {
17377+ return Err("Expected output dir path as second argument.".into());
17378+ };
17379+ let root_url_prefix = args.get(3).cloned().unwrap_or_default();
17380+
17381+ let output_path = std::path::Path::new(&output_path);
17382+ if output_path.exists() && !output_path.is_dir() {
17383+ return Err("Output path is not a directory.".into());
17384+ }
17385+
17386+ std::fs::create_dir_all(output_path.join("lists"))?;
17387+ std::fs::create_dir_all(output_path.join("list"))?;
17388+ let conf = Configuration::from_file(config_path)
17389+ .map_err(|err| format!("Could not load config {config_path}: {err}"))?;
17390+
17391+ let db = Connection::open_db(conf).map_err(|err| format!("Couldn't open db: {err}"))?;
17392+ let lists_values = db.lists()?;
17393+ {
17394+ //index.html
17395+
17396+ let lists = lists_values
17397+ .iter()
17398+ .map(|list| {
17399+ let months = db.months(list.pk).unwrap();
17400+ let posts = db.list_posts(list.pk, None).unwrap();
17401+ minijinja::context! {
17402+ title => &list.name,
17403+ posts => &posts,
17404+ months => &months,
17405+ body => &list.description.as_deref().unwrap_or_default(),
17406+ root_prefix => &root_url_prefix,
17407+ list => Value::from_object(MailingList::from(list.clone())),
17408+ }
17409+ })
17410+ .collect::<Vec<_>>();
17411+ let mut file = OpenOptions::new()
17412+ .write(true)
17413+ .create(true)
17414+ .truncate(true)
17415+ .open(output_path.join("index.html"))?;
17416+ let crumbs = vec![Crumb {
17417+ label: "Lists".into(),
17418+ url: format!("{root_url_prefix}/").into(),
17419+ }];
17420+
17421+ let context = minijinja::context! {
17422+ title => "mailing list archive",
17423+ description => "",
17424+ lists => &lists,
17425+ root_prefix => &root_url_prefix,
17426+ crumbs => crumbs,
17427+ };
17428+ file.write_all(
17429+ TEMPLATES
17430+ .get_template("lists.html")?
17431+ .render(context)?
17432+ .as_bytes(),
17433+ )?;
17434+ }
17435+
17436+ let mut lists_path = output_path.to_path_buf();
17437+
17438+ for list in &lists_values {
17439+ lists_path.push("lists");
17440+ lists_path.push(list.pk.to_string());
17441+ std::fs::create_dir_all(&lists_path)?;
17442+ lists_path.push("index.html");
17443+
17444+ let list = db.list(list.pk)?.unwrap();
17445+ let post_policy = db.list_post_policy(list.pk)?;
17446+ let months = db.months(list.pk)?;
17447+ let posts = db.list_posts(list.pk, None)?;
17448+ let mut hist = months
17449+ .iter()
17450+ .map(|m| (m.to_string(), [0usize; 31]))
17451+ .collect::<std::collections::HashMap<String, [usize; 31]>>();
17452+ let posts_ctx = posts
17453+ .iter()
17454+ .map(|post| {
17455+ //2019-07-14T14:21:02
17456+ if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
17457+ hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
17458+ }
17459+ let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
17460+ .expect("Could not parse mail");
17461+ let mut msg_id = &post.message_id[1..];
17462+ msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
17463+ let subject = envelope.subject();
17464+ let mut subject_ref = subject.trim();
17465+ if subject_ref.starts_with('[')
17466+ && subject_ref[1..].starts_with(&list.id)
17467+ && subject_ref[1 + list.id.len()..].starts_with(']')
17468+ {
17469+ subject_ref = subject_ref[2 + list.id.len()..].trim();
17470+ }
17471+ minijinja::context! {
17472+ pk => post.pk,
17473+ list => post.list,
17474+ subject => subject_ref,
17475+ address=> post.address,
17476+ message_id => msg_id,
17477+ message => post.message,
17478+ timestamp => post.timestamp,
17479+ datetime => post.datetime,
17480+ root_prefix => &root_url_prefix,
17481+ }
17482+ })
17483+ .collect::<Vec<_>>();
17484+ let crumbs = vec![
17485+ Crumb {
17486+ label: "Lists".into(),
17487+ url: format!("{root_url_prefix}/").into(),
17488+ },
17489+ Crumb {
17490+ label: list.name.clone().into(),
17491+ url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
17492+ },
17493+ ];
17494+ let context = minijinja::context! {
17495+ title=> &list.name,
17496+ description=> &list.description,
17497+ post_policy=> &post_policy,
17498+ preamble => true,
17499+ months=> &months,
17500+ hists => &hist,
17501+ posts=> posts_ctx,
17502+ body=>&list.description.clone().unwrap_or_default(),
17503+ root_prefix => &root_url_prefix,
17504+ list => Value::from_object(MailingList::from(list.clone())),
17505+ crumbs => crumbs,
17506+ };
17507+ let mut file = OpenOptions::new()
17508+ .read(true)
17509+ .write(true)
17510+ .create(true)
17511+ .truncate(true)
17512+ .open(&lists_path)
17513+ .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
17514+ file.write_all(
17515+ TEMPLATES
17516+ .get_template("list.html")?
17517+ .render(context)?
17518+ .as_bytes(),
17519+ )?;
17520+ lists_path.pop();
17521+ lists_path.pop();
17522+ lists_path.pop();
17523+ lists_path.push("list");
17524+ lists_path.push(list.pk.to_string());
17525+ std::fs::create_dir_all(&lists_path)?;
17526+
17527+ for post in posts {
17528+ let mut msg_id = &post.message_id[1..];
17529+ msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
17530+ lists_path.push(format!("{msg_id}.html"));
17531+ let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
17532+ .map_err(|err| format!("Could not parse mail {}: {err}", post.message_id))?;
17533+ let body = envelope.body_bytes(post.message.as_slice());
17534+ let body_text = body.text();
17535+ let subject = envelope.subject();
17536+ let mut subject_ref = subject.trim();
17537+ if subject_ref.starts_with('[')
17538+ && subject_ref[1..].starts_with(&list.id)
17539+ && subject_ref[1 + list.id.len()..].starts_with(']')
17540+ {
17541+ subject_ref = subject_ref[2 + list.id.len()..].trim();
17542+ }
17543+ let mut message_id = &post.message_id[1..];
17544+ message_id = &message_id[..message_id.len().saturating_sub(1)];
17545+ let crumbs = vec![
17546+ Crumb {
17547+ label: "Lists".into(),
17548+ url: format!("{root_url_prefix}/").into(),
17549+ },
17550+ Crumb {
17551+ label: list.name.clone().into(),
17552+ url: format!("{root_url_prefix}/lists/{}/", list.pk).into(),
17553+ },
17554+ Crumb {
17555+ label: subject_ref.to_string().into(),
17556+ url: format!("{root_url_prefix}/lists/{}/{message_id}.html/", list.pk).into(),
17557+ },
17558+ ];
17559+ let context = minijinja::context! {
17560+ title => &list.name,
17561+ list => &list,
17562+ post => &post,
17563+ posts => &posts_ctx,
17564+ body => &body_text,
17565+ from => &envelope.field_from_to_string(),
17566+ date => &envelope.date_as_str(),
17567+ to => &envelope.field_to_to_string(),
17568+ subject => &envelope.subject(),
17569+ trimmed_subject => subject_ref,
17570+ in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
17571+ references => &envelope .references() .into_iter() .map(|m| m.to_string().as_str().strip_carets().to_string()) .collect::<Vec<String>>(),
17572+ root_prefix => &root_url_prefix,
17573+ crumbs => crumbs,
17574+ };
17575+ let mut file = OpenOptions::new()
17576+ .read(true)
17577+ .write(true)
17578+ .create(true)
17579+ .truncate(true)
17580+ .open(&lists_path)
17581+ .map_err(|err| format!("could not open {lists_path:?}: {err}"))?;
17582+ file.write_all(
17583+ TEMPLATES
17584+ .get_template("post.html")?
17585+ .render(context)?
17586+ .as_bytes(),
17587+ )?;
17588+ lists_path.pop();
17589+ }
17590+ lists_path.pop();
17591+ lists_path.pop();
17592+ }
17593+ Ok(())
17594+ }
17595+
17596+ fn main() -> std::result::Result<(), i64> {
17597+ if let Err(err) = run_app() {
17598+ eprintln!("{err}");
17599+ return Err(-1);
17600+ }
17601+ Ok(())
17602+ }
17603 diff --git a/mailpot-archives/src/templates/calendar.html b/mailpot-archives/src/templates/calendar.html
17604new file mode 100644
17605index 0000000..22e4668
17606--- /dev/null
17607+++ b/mailpot-archives/src/templates/calendar.html
17608 @@ -0,0 +1,43 @@
17609+ {% macro cal(date, hists, root_prefix, pk) %}
17610+ {% set c=calendarize(date, hists) %}
17611+ {% if c.sum > 0 %}
17612+ <table>
17613+ <caption align="top">
17614+ <!--<a href="{{ root_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
17615+ <a href="#" style="color: GrayText;">
17616+ {{ c.month_name }} {{ c.year }}
17617+ </a>
17618+ </caption>
17619+ <thead>
17620+ <tr>
17621+ <th>M</th>
17622+ <th>Tu</th>
17623+ <th>W</th>
17624+ <th>Th</th>
17625+ <th>F</th>
17626+ <th>Sa</th>
17627+ <th>Su</th>
17628+ </tr>
17629+ </thead>
17630+ <tbody>
17631+ {% for week in c.weeks %}
17632+ <tr>
17633+ {% for day in week %}
17634+ {% if day == 0 %}
17635+ <td></td>
17636+ {% else %}
17637+ {% set num = c.hist[day-1] %}
17638+ {% if num > 0 %}
17639+ <td><ruby>{{ day }}<rt>({{ num }})</rt></ruby></td>
17640+ {% else %}
17641+ <td class="empty">{{ day }}</td>
17642+ {% endif %}
17643+ {% endif %}
17644+ {% endfor %}
17645+ </tr>
17646+ {% endfor %}
17647+ </tbody>
17648+ </table>
17649+ {% endif %}
17650+ {% endmacro %}
17651+ {% set alias = cal %}
17652 diff --git a/mailpot-archives/src/templates/css.html b/mailpot-archives/src/templates/css.html
17653new file mode 100644
17654index 0000000..1f5d06b
17655--- /dev/null
17656+++ b/mailpot-archives/src/templates/css.html
17657 @@ -0,0 +1,307 @@
17658+ <style>
17659+ @charset "UTF-8";
17660+ * Use a more intuitive box-sizing model */
17661+ *, *::before, *::after {
17662+ box-sizing: border-box;
17663+ }
17664+
17665+ /* Remove all margins & padding */
17666+ * {
17667+ margin: 0;
17668+ padding: 0;
17669+ }
17670+
17671+ /* Only show focus outline when the user is tabbing (not when clicking) */
17672+ *:focus {
17673+ outline: none;
17674+ }
17675+
17676+ *:focus-visible {
17677+ outline: 1px solid blue;
17678+ }
17679+
17680+ /* Prevent mobile browsers increasing font-size */
17681+ html {
17682+ -moz-text-size-adjust: none;
17683+ -webkit-text-size-adjust: none;
17684+ text-size-adjust: none;
17685+ font-family:-apple-system,BlinkMacSystemFont,Arial,sans-serif;
17686+ line-height:1.15;
17687+ -webkit-text-size-adjust:100%;
17688+ overflow-y:scroll;
17689+ }
17690+
17691+ /* Allow percentage-based heights */
17692+ /* Setting width: 100% isn't required because it is a default for block-level elements (html & body are block level) */
17693+ html, body {
17694+ height: 100%;
17695+ }
17696+
17697+ body {
17698+ /* Prevent the rubber band effect when the user scrolls to the top or bottom of the page (WebKit only) */
17699+ overscroll-behavior: none;
17700+
17701+ /* Prevent the browser from synthesizing missing typefaces */
17702+ font-synthesis: none;
17703+
17704+ color: black;
17705+ /* UI controls color (example: range input) */
17706+ accent-color: black;
17707+
17708+ /* Because overscroll-behavior: none only works on WebKit, a background color is set that will show when overscroll occurs */
17709+ background: white;
17710+ margin:0;
17711+ font-feature-settings:"onum" 1;
17712+ text-rendering:optimizeLegibility;
17713+ -webkit-font-smoothing:antialiased;
17714+ -moz-osx-font-smoothing:grayscale;
17715+ font-family:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif;
17716+ font-size:1.125em
17717+ }
17718+
17719+ /* Remove unintuitive behaviour such as gaps around media elements. */
17720+ img, picture, video, canvas, svg, iframe {
17721+ display: block;
17722+ }
17723+
17724+ /* Avoid text overflow */
17725+ h1, h2, h3, h4, h5, h6, p, strong {
17726+ overflow-wrap: break-word;
17727+ }
17728+
17729+ a {
17730+ text-decoration: none;
17731+ }
17732+
17733+ ul, ol {
17734+ list-style: none;
17735+ }
17736+
17737+ input {
17738+ border: none;
17739+ }
17740+
17741+ input, button, textarea, select {
17742+ font: inherit;
17743+ }
17744+
17745+ /* Create a root stacking context (only when using frameworks like Next.js) */
17746+ #__next {
17747+ isolation: isolate;
17748+ }
17749+
17750+
17751+ body>main.layout {
17752+ width: 100%;
17753+ overflow-wrap: anywhere;
17754+
17755+ display: grid;
17756+ grid:
17757+ "header header header" auto
17758+ "leftside body rightside" 1fr
17759+ "footer footer footer" auto
17760+ / auto 1fr auto;
17761+ gap: 8px;
17762+ }
17763+
17764+ main.layout>.header { grid-area: header; }
17765+ main.layout>.leftside { grid-area: leftside; }
17766+ main.layout>div.body { grid-area: body; }
17767+ main.layout>.rightside { grid-area: rightside; }
17768+ main.layout>footer {
17769+ grid-area: footer;
17770+ padding: 1rem 2rem;
17771+ }
17772+
17773+ main.layout>div.header>h1 {
17774+ margin: 1rem;
17775+ }
17776+
17777+ main.layout>div.body h2 {
17778+ margin: 1rem;
17779+ }
17780+
17781+ nav.breadcrumb ul:before {
17782+ content: "≫";
17783+ display: inline-block;
17784+ margin-right: 0.6rem;
17785+ }
17786+
17787+ .breadcrumb a {
17788+ padding: 0.4rem;
17789+ margin: -0.4rem;
17790+ font-size: larger;
17791+ }
17792+
17793+ .breadcrumb>ul>li:first-child a {
17794+ padding-left: 0rem;
17795+ }
17796+
17797+ .breadcrumb {
17798+ padding: 0rem 0.5rem;
17799+ margin: 1rem;
17800+ }
17801+
17802+ .breadcrumb span[aria-current="page"] {
17803+ color: GrayText;
17804+ vertical-align: sub;
17805+ padding: 0.4rem;
17806+ margin-left: -0.4rem;
17807+ }
17808+
17809+ .breadcrumb ul {
17810+ display: flex;
17811+ flex-wrap: wrap;
17812+ list-style: none;
17813+ margin: 0;
17814+ padding: 0;
17815+ }
17816+
17817+ .breadcrumb li:not(:last-child)::after {
17818+ display: inline-block;
17819+ margin: 0rem 0.25rem;
17820+ content: "→";
17821+ vertical-align: text-bottom;
17822+ }
17823+
17824+ div.preamble {
17825+ border-left: 0.2rem solid GrayText;
17826+ padding-left: 0.5rem;
17827+ }
17828+
17829+ div.calendar th {
17830+ padding: 0.5rem;
17831+ opacity: 0.7;
17832+ }
17833+
17834+ div.calendar tr,
17835+ div.calendar th {
17836+ text-align: right;
17837+ font-variant-numeric: tabular-nums;
17838+ font-family: monospace;
17839+ }
17840+
17841+ div.calendar table {
17842+ display: inline-table;
17843+ border-collapse: collapse;
17844+ }
17845+
17846+ div.calendar td {
17847+ padding: 0.1rem 0.4rem;
17848+ }
17849+
17850+ div.calendar td.empty {
17851+ color: GrayText;
17852+ }
17853+
17854+ div.calendar td:not(.empty) {
17855+ font-weight: bold;
17856+ }
17857+
17858+ div.calendar td:not(:empty) {
17859+ border: 1px solid black;
17860+ }
17861+
17862+ div.calendar td:empty {
17863+ background: GrayText;
17864+ opacity: 0.3;
17865+ }
17866+
17867+ div.calendar {
17868+ display: flex;
17869+ flex-wrap: wrap;
17870+ flex-direction: row;
17871+ gap: 1rem;
17872+ align-items: baseline;
17873+ }
17874+
17875+ div.calendar caption {
17876+ font-weight: bold;
17877+ }
17878+
17879+ div.posts {
17880+ display: flex;
17881+ flex-direction: column;
17882+ gap: 1rem;
17883+ }
17884+
17885+ div.posts>div.entry {
17886+ display: flex;
17887+ flex-direction: column;
17888+ gap: 0.5rem;
17889+ }
17890+
17891+ div.posts>div.entry>span.subject {
17892+ font-size: larger;
17893+ }
17894+
17895+ div.posts>div.entry>span.metadata {
17896+ color: GrayText;
17897+ }
17898+
17899+ div.posts>div.entry>span.metadata>span.from {
17900+ margin-inline-end: 1rem;
17901+ }
17902+
17903+ table.headers tr>th {
17904+ text-align: right;
17905+ color: GrayText;
17906+ }
17907+ table.headers th[scope="row"] {
17908+ padding-right: .5rem;
17909+ }
17910+ table.headers tr>th:after {
17911+ content:':';
17912+ display: inline-block;
17913+ }
17914+ div.post-body {
17915+ margin: 1rem;
17916+ }
17917+ div.post-body>pre {
17918+ max-width: 98vw;
17919+ overflow-wrap: break-word;
17920+ white-space: pre-line;
17921+ }
17922+ td.message-id,
17923+ span.message-id{
17924+ color: GrayText;
17925+ }
17926+ td.message-id:before,
17927+ span.message-id:before{
17928+ content:'<';
17929+ display: inline-block;
17930+ }
17931+ td.message-id:after,
17932+ span.message-id:after{
17933+ content:'>';
17934+ display: inline-block;
17935+ }
17936+ span.message-id + span.message-id:before{
17937+ content:', <';
17938+ display: inline-block;
17939+ }
17940+ td.faded,
17941+ span.faded {
17942+ color: GrayText;
17943+ }
17944+ td.faded:is(:focus, :hover, :focus-visible, :focus-within),
17945+ span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
17946+ color: revert;
17947+ }
17948+
17949+ ul.lists {
17950+ padding: 1rem 2rem;
17951+ }
17952+
17953+ ul.lists li {
17954+ list-style: disc;
17955+ }
17956+
17957+ ul.lists li + li {
17958+ margin-top: 1rem;
17959+ }
17960+
17961+ hr {
17962+ margin: 1rem 0rem;
17963+ }
17964+ </style>
17965 diff --git a/mailpot-archives/src/templates/footer.html b/mailpot-archives/src/templates/footer.html
17966new file mode 100644
17967index 0000000..048935f
17968--- /dev/null
17969+++ b/mailpot-archives/src/templates/footer.html
17970 @@ -0,0 +1,8 @@
17971+ <footer>
17972+ <hr />
17973+ <p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
17974+ </footer>
17975+ </main>
17976+ </body>
17977+ </html>
17978+
17979 diff --git a/mailpot-archives/src/templates/header.html b/mailpot-archives/src/templates/header.html
17980new file mode 100644
17981index 0000000..d7c2c0c
17982--- /dev/null
17983+++ b/mailpot-archives/src/templates/header.html
17984 @@ -0,0 +1,17 @@
17985+ <!DOCTYPE html>
17986+ <html lang="en">
17987+ <head>
17988+ <meta charset="utf-8">
17989+ <title>{{ title }}</title>
17990+ {% include "css.html" %}
17991+ </head>
17992+ <body>
17993+ <main class="layout">
17994+ <div class="header">
17995+ <h1>{{ title }}</h1>
17996+ {% if description %}
17997+ <p class="description">{{ description }}</p>
17998+ {% endif %}
17999+ {% include "menu.html" %}
18000+ <hr />
18001+ </div>
18002 diff --git a/mailpot-archives/src/templates/index.html b/mailpot-archives/src/templates/index.html
18003new file mode 100644
18004index 0000000..33620c4
18005--- /dev/null
18006+++ b/mailpot-archives/src/templates/index.html
18007 @@ -0,0 +1,12 @@
18008+ {% include "header.html" %}
18009+ <div class="entry">
18010+ <h1>{{title}}</h1>
18011+ <div class="body">
18012+ <ul>
18013+ {% for l in lists %}
18014+ <li><a href="{{ root_prefix|safe }}/lists/{{ l.list.pk }}/">{{l.title}}</a></li>
18015+ {% endfor %}
18016+ </ul>
18017+ </div>
18018+ </div>
18019+ {% include "footer.html" %}
18020 diff --git a/mailpot-archives/src/templates/list.html b/mailpot-archives/src/templates/list.html
18021new file mode 100644
18022index 0000000..3133a3b
18023--- /dev/null
18024+++ b/mailpot-archives/src/templates/list.html
18025 @@ -0,0 +1,82 @@
18026+ {% include "header.html" %}
18027+ <div class="body">
18028+ {% if preamble %}
18029+ <div id="preamble" class="preamble">
18030+ {% if preamble.custom %}
18031+ {{ preamble.custom|safe }}
18032+ {% else %}
18033+ {% if not post_policy.no_subscriptions %}
18034+ <h2 id="subscribe">Subscribe</h2>
18035+ {% set subscription_mailto=list.subscription_mailto() %}
18036+ {% if subscription_mailto %}
18037+ {% if subscription_mailto.subject %}
18038+ <p>
18039+ <a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
18040+ </p>
18041+ {% else %}
18042+ <p>
18043+ <a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
18044+ </p>
18045+ {% endif %}
18046+ {% else %}
18047+ <p>List is not open for subscriptions.</p>
18048+ {% endif %}
18049+
18050+ {% set unsubscription_mailto=list.unsubscription_mailto() %}
18051+ {% if unsubscription_mailto %}
18052+ <h2 id="unsubscribe">Unsubscribe</h2>
18053+ {% if unsubscription_mailto.subject %}
18054+ <p>
18055+ <a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
18056+ </p>
18057+ {% else %}
18058+ <p>
18059+ <a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
18060+ </p>
18061+ {% endif %}
18062+ {% endif %}
18063+ {% endif %}
18064+
18065+ <h2 id="post">Post</h2>
18066+ {% if post_policy.announce_only %}
18067+ <p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
18068+ {% elif post_policy.subscription_only %}
18069+ <p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
18070+ <p>If you are subscribed, you can send new posts to:
18071+ <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
18072+ </p>
18073+ {% elif post_policy.approval_needed or post_policy.no_subscriptions %}
18074+ <p>List is open to all posts <em>after approval</em> by the list owners.</p>
18075+ <p>You can send new posts to:
18076+ <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
18077+ </p>
18078+ {% else %}
18079+ <p>List is not open for submissions.</p>
18080+ {% endif %}
18081+ {% endif %}
18082+ </div>
18083+ <hr />
18084+ {% endif %}
18085+ <div class="list">
18086+ <h2 id="calendar">Calendar</h2>
18087+ <div class="calendar">
18088+ {%- from "calendar.html" import cal %}
18089+ {% for date in months %}
18090+ {{ cal(date, hists, root_prefix, list.pk) }}
18091+ {% endfor %}
18092+ </div>
18093+ <hr />
18094+ <h2 id="posts">Posts</h2>
18095+ <div class="posts">
18096+ <p>{{ posts | length }} post(s)</p>
18097+ {% for post in posts %}
18098+ <div class="entry">
18099+ <span class="subject"><a href="{{ root_prefix|safe }}/list/{{post.list}}/{{ post.message_id }}.html">{{ post.subject }}</a></span>
18100+ <span class="metadata">👤&nbsp;<span class="from">{{ post.address }}</span> 📆&nbsp;<span class="date">{{ post.datetime }}</span></span>
18101+ <span class="metadata">🪪 &nbsp;<span class="message-id">{{ post.message_id }}</span></span>
18102+ </div>
18103+ {% endfor %}
18104+ </div>
18105+ </div>
18106+ </div>
18107+ {% include "footer.html" %}
18108 diff --git a/mailpot-archives/src/templates/lists.html b/mailpot-archives/src/templates/lists.html
18109new file mode 100644
18110index 0000000..825c17b
18111--- /dev/null
18112+++ b/mailpot-archives/src/templates/lists.html
18113 @@ -0,0 +1,12 @@
18114+ {% include "header.html" %}
18115+ <div class="body">
18116+ <p>{{lists|length}} lists</p>
18117+ <div class="entry">
18118+ <ul class="lists">
18119+ {% for l in lists %}
18120+ <li><a href="{{ root_prefix|safe }}/lists/{{ l.list.pk }}/">{{l.title}}</a></li>
18121+ {% endfor %}
18122+ </ul>
18123+ </div>
18124+ </div>
18125+ {% include "footer.html" %}
18126 diff --git a/mailpot-archives/src/templates/menu.html b/mailpot-archives/src/templates/menu.html
18127new file mode 100644
18128index 0000000..687a36e
18129--- /dev/null
18130+++ b/mailpot-archives/src/templates/menu.html
18131 @@ -0,0 +1,11 @@
18132+ <nav aria-label="Breadcrumb" class="breadcrumb">
18133+ <ul>
18134+ {% for crumb in crumbs %}
18135+ {% if loop.last %}
18136+ <li><span aria-current="page">{{ crumb.label }}</span></li>
18137+ {% else %}
18138+ <li><a href="{{ crumb.url }}">{{ crumb.label }}</a></li>
18139+ {% endif %}
18140+ {% endfor %}
18141+ </ul>
18142+ </nav>
18143 diff --git a/mailpot-archives/src/templates/post.html b/mailpot-archives/src/templates/post.html
18144new file mode 100644
18145index 0000000..c5bf155
18146--- /dev/null
18147+++ b/mailpot-archives/src/templates/post.html
18148 @@ -0,0 +1,42 @@
18149+ {% include "header.html" %}
18150+ <div class="body">
18151+ <h2>{{trimmed_subject}}</h2>
18152+ <table class="headers">
18153+ <tr>
18154+ <th scope="row">List</th>
18155+ <td class="faded">{{ list.id }}</td>
18156+ </tr>
18157+ <tr>
18158+ <th scope="row">From</th>
18159+ <td>{{ from }}</td>
18160+ </tr>
18161+ <tr>
18162+ <th scope="row">To</th>
18163+ <td class="faded">{{ to }}</td>
18164+ </tr>
18165+ <tr>
18166+ <th scope="row">Subject</th>
18167+ <td>{{ subject }}</td>
18168+ </tr>
18169+ <tr>
18170+ <th scope="row">Date</th>
18171+ <td class="faded">{{ date }}</td>
18172+ </tr>
18173+ {% if in_reply_to %}
18174+ <tr>
18175+ <th scope="row">In-Reply-To</th>
18176+ <td class="faded message-id"><a href="{{ root_prefix|safe }}/list/{{list.pk}}/{{ in_reply_to }}.html">{{ in_reply_to }}</a></td>
18177+ </tr>
18178+ {% endif %}
18179+ {% if references %}
18180+ <tr>
18181+ <th scope="row">References</th>
18182+ <td>{% for r in references %}<span class="faded message-id"><a href="{{ root_prefix|safe }}/list/{{list.pk}}/{{ r }}.html">{{ r }}</a></span>{% endfor %}</td>
18183+ </tr>
18184+ {% endif %}
18185+ </table>
18186+ <div class="post-body">
18187+ <pre>{{body}}</pre>
18188+ </div>
18189+ </div>
18190+ {% include "footer.html" %}
18191 diff --git a/mailpot-archives/src/utils.rs b/mailpot-archives/src/utils.rs
18192new file mode 100644
18193index 0000000..71905b5
18194--- /dev/null
18195+++ b/mailpot-archives/src/utils.rs
18196 @@ -0,0 +1,207 @@
18197+ /*
18198+ * This file is part of mailpot
18199+ *
18200+ * Copyright 2020 - Manos Pitsidianakis
18201+ *
18202+ * This program is free software: you can redistribute it and/or modify
18203+ * it under the terms of the GNU Affero General Public License as
18204+ * published by the Free Software Foundation, either version 3 of the
18205+ * License, or (at your option) any later version.
18206+ *
18207+ * This program is distributed in the hope that it will be useful,
18208+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
18209+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18210+ * GNU Affero General Public License for more details.
18211+ *
18212+ * You should have received a copy of the GNU Affero General Public License
18213+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
18214+ */
18215+
18216+ use std::borrow::Cow;
18217+
18218+ use chrono::{Datelike, Month};
18219+ use mailpot::{models::DbVal, *};
18220+ use minijinja::{
18221+ value::{Object, Value},
18222+ Environment, Error, Source, State,
18223+ };
18224+
18225+ lazy_static::lazy_static! {
18226+ pub static ref TEMPLATES: Environment<'static> = {
18227+ let mut env = Environment::new();
18228+ env.add_function("calendarize", calendarize);
18229+ env.set_source(Source::from_path("src/templates/"));
18230+
18231+ env
18232+ };
18233+ }
18234+
18235+ pub trait StripCarets {
18236+ fn strip_carets(&self) -> &str;
18237+ }
18238+
18239+ impl StripCarets for &str {
18240+ fn strip_carets(&self) -> &str {
18241+ let mut self_ref = self.trim();
18242+ if self_ref.starts_with('<') && self_ref.ends_with('>') {
18243+ self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
18244+ }
18245+ self_ref
18246+ }
18247+ }
18248+
18249+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
18250+ pub struct MailingList {
18251+ pub pk: i64,
18252+ pub name: String,
18253+ pub id: String,
18254+ pub address: String,
18255+ pub description: Option<String>,
18256+ pub topics: Vec<String>,
18257+ pub archive_url: Option<String>,
18258+ pub inner: DbVal<mailpot::models::MailingList>,
18259+ }
18260+
18261+ impl From<DbVal<mailpot::models::MailingList>> for MailingList {
18262+ fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
18263+ let DbVal(
18264+ mailpot::models::MailingList {
18265+ pk,
18266+ name,
18267+ id,
18268+ address,
18269+ description,
18270+ topics,
18271+ archive_url,
18272+ },
18273+ _,
18274+ ) = val.clone();
18275+
18276+ Self {
18277+ pk,
18278+ name,
18279+ id,
18280+ address,
18281+ description,
18282+ topics,
18283+ archive_url,
18284+ inner: val,
18285+ }
18286+ }
18287+ }
18288+
18289+ impl std::fmt::Display for MailingList {
18290+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
18291+ self.id.fmt(fmt)
18292+ }
18293+ }
18294+
18295+ impl Object for MailingList {
18296+ fn kind(&self) -> minijinja::value::ObjectKind {
18297+ minijinja::value::ObjectKind::Struct(self)
18298+ }
18299+
18300+ fn call_method(
18301+ &self,
18302+ _state: &State,
18303+ name: &str,
18304+ _args: &[Value],
18305+ ) -> std::result::Result<Value, Error> {
18306+ match name {
18307+ "subscription_mailto" => {
18308+ Ok(Value::from_serializable(&self.inner.subscription_mailto()))
18309+ }
18310+ "unsubscription_mailto" => Ok(Value::from_serializable(
18311+ &self.inner.unsubscription_mailto(),
18312+ )),
18313+ _ => Err(Error::new(
18314+ minijinja::ErrorKind::UnknownMethod,
18315+ format!("aaaobject has no method named {name}"),
18316+ )),
18317+ }
18318+ }
18319+ }
18320+
18321+ impl minijinja::value::StructObject for MailingList {
18322+ fn get_field(&self, name: &str) -> Option<Value> {
18323+ match name {
18324+ "pk" => Some(Value::from_serializable(&self.pk)),
18325+ "name" => Some(Value::from_serializable(&self.name)),
18326+ "id" => Some(Value::from_serializable(&self.id)),
18327+ "address" => Some(Value::from_serializable(&self.address)),
18328+ "description" => Some(Value::from_serializable(&self.description)),
18329+ "topics" => Some(Value::from_serializable(&self.topics)),
18330+ "archive_url" => Some(Value::from_serializable(&self.archive_url)),
18331+ _ => None,
18332+ }
18333+ }
18334+
18335+ fn static_fields(&self) -> Option<&'static [&'static str]> {
18336+ Some(
18337+ &[
18338+ "pk",
18339+ "name",
18340+ "id",
18341+ "address",
18342+ "description",
18343+ "topics",
18344+ "archive_url",
18345+ ][..],
18346+ )
18347+ }
18348+ }
18349+
18350+ pub fn calendarize(_state: &State, args: Value, hists: Value) -> std::result::Result<Value, Error> {
18351+ macro_rules! month {
18352+ ($int:expr) => {{
18353+ let int = $int;
18354+ match int {
18355+ 1 => Month::January.name(),
18356+ 2 => Month::February.name(),
18357+ 3 => Month::March.name(),
18358+ 4 => Month::April.name(),
18359+ 5 => Month::May.name(),
18360+ 6 => Month::June.name(),
18361+ 7 => Month::July.name(),
18362+ 8 => Month::August.name(),
18363+ 9 => Month::September.name(),
18364+ 10 => Month::October.name(),
18365+ 11 => Month::November.name(),
18366+ 12 => Month::December.name(),
18367+ _ => unreachable!(),
18368+ }
18369+ }};
18370+ }
18371+ let month = args.as_str().unwrap();
18372+ let hist = hists
18373+ .get_item(&Value::from(month))?
18374+ .as_seq()
18375+ .unwrap()
18376+ .iter()
18377+ .map(|v| usize::try_from(v).unwrap())
18378+ .collect::<Vec<usize>>();
18379+ let sum: usize = hists
18380+ .get_item(&Value::from(month))?
18381+ .as_seq()
18382+ .unwrap()
18383+ .iter()
18384+ .map(|v| usize::try_from(v).unwrap())
18385+ .sum();
18386+ let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
18387+ // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
18388+ Ok(minijinja::context! {
18389+ month_name => month!(date.month()),
18390+ month => month,
18391+ month_int => date.month() as usize,
18392+ year => date.year(),
18393+ weeks => crate::cal::calendarize_with_offset(date, 1),
18394+ hist => hist,
18395+ sum => sum,
18396+ })
18397+ }
18398+
18399+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
18400+ pub struct Crumb {
18401+ pub label: Cow<'static, str>,
18402+ pub url: Cow<'static, str>,
18403+ }
18404 diff --git a/mailpot-cli/Cargo.toml b/mailpot-cli/Cargo.toml
18405new file mode 100644
18406index 0000000..44bc8de
18407--- /dev/null
18408+++ b/mailpot-cli/Cargo.toml
18409 @@ -0,0 +1,39 @@
18410+ [package]
18411+ name = "mailpot-cli"
18412+ version = "0.1.1"
18413+ authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
18414+ edition = "2021"
18415+ license = "LICENSE"
18416+ readme = "README.md"
18417+ description = "mailing list manager"
18418+ repository = "https://github.com/meli/mailpot"
18419+ keywords = ["mail", "mailing-lists"]
18420+ categories = ["email"]
18421+ default-run = "mpot"
18422+
18423+ [[bin]]
18424+ name = "mpot"
18425+ path = "src/main.rs"
18426+ doc-scrape-examples = true
18427+
18428+ [dependencies]
18429+ base64 = { version = "0.21" }
18430+ clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "help", "usage", "error-context", "suggestions"] }
18431+ log = "0.4"
18432+ mailpot = { version = "^0.1", path = "../mailpot" }
18433+ serde = { version = "^1", features = ["derive", ] }
18434+ serde_json = "^1"
18435+ stderrlog = { version = "^0.6" }
18436+ ureq = { version = "2.6", default-features = false }
18437+
18438+ [dev-dependencies]
18439+ assert_cmd = "2"
18440+ mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
18441+ predicates = "3"
18442+ tempfile = { version = "3.9" }
18443+
18444+ [build-dependencies]
18445+ clap = { version = "^4.2", default-features = false, features = ["std", "derive", "cargo", "unicode", "wrap_help", "help", "usage", "error-context", "suggestions"] }
18446+ clap_mangen = "0.2.10"
18447+ mailpot = { version = "^0.1", path = "../mailpot" }
18448+ stderrlog = { version = "^0.6" }
18449 diff --git a/mailpot-cli/README.md b/mailpot-cli/README.md
18450new file mode 100644
18451index 0000000..f5e323d
18452--- /dev/null
18453+++ b/mailpot-cli/README.md
18454 @@ -0,0 +1,5 @@
18455+ # mailpot-cli
18456+
18457+ ```shell
18458+ cargo run --bin mpot -- help
18459+ ```
18460 diff --git a/mailpot-cli/build.rs b/mailpot-cli/build.rs
18461new file mode 100644
18462index 0000000..2f0db6d
18463--- /dev/null
18464+++ b/mailpot-cli/build.rs
18465 @@ -0,0 +1,524 @@
18466+ /*
18467+ * This file is part of mailpot
18468+ *
18469+ * Copyright 2020 - Manos Pitsidianakis
18470+ *
18471+ * This program is free software: you can redistribute it and/or modify
18472+ * it under the terms of the GNU Affero General Public License as
18473+ * published by the Free Software Foundation, either version 3 of the
18474+ * License, or (at your option) any later version.
18475+ *
18476+ * This program is distributed in the hope that it will be useful,
18477+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
18478+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18479+ * GNU Affero General Public License for more details.
18480+ *
18481+ * You should have received a copy of the GNU Affero General Public License
18482+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
18483+ */
18484+
18485+ use std::{
18486+ collections::{hash_map::RandomState, HashSet, VecDeque},
18487+ hash::{BuildHasher, Hasher},
18488+ io::Write,
18489+ };
18490+
18491+ use clap::{ArgAction, CommandFactory};
18492+ use clap_mangen::{roff, Man};
18493+ use roff::{bold, italic, roman, Inline, Roff};
18494+
18495+ include!("src/args.rs");
18496+
18497+ fn main() -> std::io::Result<()> {
18498+ println!("cargo:rerun-if-changed=./src/lib.rs");
18499+ println!("cargo:rerun-if-changed=./build.rs");
18500+ std::env::set_current_dir("..").expect("could not chdir('..')");
18501+
18502+ let out_dir = PathBuf::from("./docs/");
18503+
18504+ let cmd = Opt::command();
18505+
18506+ let man = Man::new(cmd.clone()).title("mpot");
18507+ let mut buffer: Vec<u8> = Default::default();
18508+ man.render_title(&mut buffer)?;
18509+ man.render_name_section(&mut buffer)?;
18510+ man.render_synopsis_section(&mut buffer)?;
18511+ man.render_description_section(&mut buffer)?;
18512+
18513+ let mut roff = Roff::default();
18514+ options(&mut roff, &cmd);
18515+ roff.to_writer(&mut buffer)?;
18516+
18517+ render_quick_start_section(&mut buffer)?;
18518+ render_subcommands_section(&mut buffer)?;
18519+
18520+ let mut visited = HashSet::new();
18521+
18522+ let mut stack = VecDeque::new();
18523+ let mut order = VecDeque::new();
18524+ stack.push_back(vec![&cmd]);
18525+ let s = RandomState::new();
18526+
18527+ 'stack: while let Some(cmds) = stack.pop_front() {
18528+ for sub in cmds.last().unwrap().get_subcommands() {
18529+ let mut hasher = s.build_hasher();
18530+ for c in cmds.iter() {
18531+ hasher.write(c.get_name().as_bytes());
18532+ }
18533+ hasher.write(sub.get_name().as_bytes());
18534+ if visited.insert(hasher.finish()) {
18535+ let mut sub_cmds = cmds.clone();
18536+ sub_cmds.push(sub);
18537+ order.push_back(sub_cmds.clone());
18538+ stack.push_front(cmds);
18539+ stack.push_front(sub_cmds);
18540+ continue 'stack;
18541+ }
18542+ }
18543+ }
18544+
18545+ while let Some(mut subs) = order.pop_front() {
18546+ let sub = subs.pop().unwrap();
18547+ render_subcommand(&subs, sub, &mut buffer)?;
18548+ }
18549+
18550+ man.render_authors_section(&mut buffer)?;
18551+
18552+ std::fs::write(out_dir.join("mpot.1"), buffer)?;
18553+
18554+ Ok(())
18555+ }
18556+
18557+ fn render_quick_start_section(w: &mut dyn Write) -> Result<(), std::io::Error> {
18558+ let mut roff = Roff::default();
18559+ let heading = "QUICK START";
18560+ roff.control("SH", [heading]);
18561+ let tutorial = r#"mailpot saves its data in a sqlite3 file. To define the location of the sqlite3 file we need a configuration file, which can be generated with:
18562+
18563+ mpot sample-config > conf.toml
18564+
18565+ Mailing lists can now be created:
18566+
18567+ mpot -c conf.toml create-list --name "my first list" --id mylist --address mylist@example.com
18568+
18569+ You can list all the mailing lists with:
18570+
18571+ mpot -c conf.toml list-lists
18572+
18573+ You should add yourself as the list owner:
18574+
18575+ mpot -c conf.toml list mylist add-list-owner --address myself@example.com --name "Nemo"
18576+
18577+ And also enable posting and subscriptions by setting list policies:
18578+
18579+ mpot -c conf.toml list mylist add-policy --subscriber-only
18580+
18581+ mpot -c conf.toml list mylist add-subscribe-policy --request --send-confirmation
18582+
18583+ To post on a mailing list or submit a list request, pipe a raw e-mail into STDIN:
18584+
18585+ mpot -c conf.toml post
18586+
18587+ You can configure your mail server to redirect e-mails addressed to your mailing lists to this command.
18588+
18589+ For postfix, you can automatically generate this configuration with:
18590+
18591+ mpot -c conf.toml print-postfix-config --user myself --binary-path /path/to/mpot
18592+
18593+ This will print the following:
18594+
18595+ - content of `transport_maps` and `local_recipient_maps`
18596+
18597+ The output must be saved in a plain text file.
18598+ Map output should be added to transport_maps and local_recipient_maps parameters in postfix's main.cf.
18599+ To make postfix be able to read them, the postmap application must be executed with the
18600+ path to the map file as its sole argument.
18601+
18602+ postmap /path/to/mylist_maps
18603+
18604+ postmap is usually distributed along with the other postfix binaries.
18605+
18606+ - `master.cf` service entry
18607+ The output must be entered in the master.cf file.
18608+ See <https://www.postfix.org/master.5.html>.
18609+
18610+ "#;
18611+ for line in tutorial.lines() {
18612+ roff.text([roman(line.trim())]);
18613+ }
18614+ roff.to_writer(w)
18615+ }
18616+ fn render_subcommands_section(w: &mut dyn Write) -> Result<(), std::io::Error> {
18617+ let mut roff = Roff::default();
18618+ let heading = "SUBCOMMANDS";
18619+ roff.control("SH", [heading]);
18620+ roff.to_writer(w)
18621+ }
18622+
18623+ fn render_subcommand(
18624+ parents: &[&clap::Command],
18625+ sub: &clap::Command,
18626+ w: &mut dyn Write,
18627+ ) -> Result<(), std::io::Error> {
18628+ let mut roff = Roff::default();
18629+ _render_subcommand_full(parents, sub, &mut roff);
18630+ options(&mut roff, sub);
18631+ roff.to_writer(w)
18632+ }
18633+
18634+ fn _render_subcommand_full(parents: &[&clap::Command], sub: &clap::Command, roff: &mut Roff) {
18635+ roff.control("\\fB", []);
18636+ roff.control(
18637+ "SS",
18638+ parents
18639+ .iter()
18640+ .map(|cmd| cmd.get_name())
18641+ .chain(std::iter::once(sub.get_name()))
18642+ .collect::<Vec<_>>(),
18643+ );
18644+ roff.control("\\fR", []);
18645+ roff.text([Inline::LineBreak]);
18646+
18647+ synopsis(roff, parents, sub);
18648+ roff.text([Inline::LineBreak]);
18649+
18650+ if let Some(about) = sub.get_about().or_else(|| sub.get_long_about()) {
18651+ let about = about.to_string();
18652+ let mut iter = about.lines();
18653+ let last = iter.nth_back(0);
18654+ for line in iter {
18655+ roff.text([roman(line.trim())]);
18656+ }
18657+ if let Some(line) = last {
18658+ roff.text([roman(format!("{}.", line.trim()))]);
18659+ }
18660+ }
18661+ }
18662+
18663+ fn synopsis(roff: &mut Roff, parents: &[&clap::Command], sub: &clap::Command) {
18664+ let mut line = parents
18665+ .iter()
18666+ .flat_map(|cmd| vec![roman(cmd.get_name()), roman(" ")].into_iter())
18667+ .chain(std::iter::once(roman(sub.get_name())))
18668+ .chain(std::iter::once(roman(" ")))
18669+ .collect::<Vec<_>>();
18670+ let arguments = sub
18671+ .get_arguments()
18672+ .filter(|i| !i.is_hide_set())
18673+ .collect::<Vec<_>>();
18674+ if arguments.is_empty() && sub.get_positionals().count() == 0 {
18675+ return;
18676+ }
18677+
18678+ roff.text([Inline::LineBreak]);
18679+
18680+ for opt in arguments {
18681+ match (opt.get_short(), opt.get_long()) {
18682+ (Some(short), Some(long)) => {
18683+ let (lhs, rhs) = option_markers(opt);
18684+ line.push(roman(lhs));
18685+ line.push(roman(format!("-{short}")));
18686+ if let Some(value) = opt.get_value_names() {
18687+ line.push(roman(" "));
18688+ line.push(italic(value.join(" ")));
18689+ }
18690+
18691+ line.push(roman("|"));
18692+ line.push(roman(format!("--{long}",)));
18693+ line.push(roman(rhs));
18694+ }
18695+ (Some(short), None) => {
18696+ let (lhs, rhs) = option_markers_single(opt);
18697+ line.push(roman(lhs));
18698+ line.push(roman(format!("-{short}")));
18699+ if let Some(value) = opt.get_value_names() {
18700+ line.push(roman(" "));
18701+ line.push(italic(value.join(" ")));
18702+ }
18703+ line.push(roman(rhs));
18704+ }
18705+ (None, Some(long)) => {
18706+ let (lhs, rhs) = option_markers_single(opt);
18707+ line.push(roman(lhs));
18708+ line.push(roman(format!("--{long}")));
18709+ if let Some(value) = opt.get_value_names() {
18710+ line.push(roman(" "));
18711+ line.push(italic(value.join(" ")));
18712+ }
18713+ line.push(roman(rhs));
18714+ }
18715+ (None, None) => continue,
18716+ };
18717+
18718+ if matches!(opt.get_action(), ArgAction::Count) {
18719+ line.push(roman("..."))
18720+ }
18721+ line.push(roman(" "));
18722+ }
18723+
18724+ for arg in sub.get_positionals() {
18725+ let (lhs, rhs) = option_markers_single(arg);
18726+ line.push(roman(lhs));
18727+ if let Some(value) = arg.get_value_names() {
18728+ line.push(italic(value.join(" ")));
18729+ } else {
18730+ line.push(italic(arg.get_id().as_str()));
18731+ }
18732+ line.push(roman(rhs));
18733+ line.push(roman(" "));
18734+ }
18735+
18736+ roff.text(line);
18737+ }
18738+
18739+ fn options(roff: &mut Roff, cmd: &clap::Command) {
18740+ let items: Vec<_> = cmd.get_arguments().filter(|i| !i.is_hide_set()).collect();
18741+
18742+ for pos in items.iter().filter(|a| a.is_positional()) {
18743+ let mut header = vec![];
18744+ let (lhs, rhs) = option_markers_single(pos);
18745+ header.push(roman(lhs));
18746+ if let Some(value) = pos.get_value_names() {
18747+ header.push(italic(value.join(" ")));
18748+ } else {
18749+ header.push(italic(pos.get_id().as_str()));
18750+ };
18751+ header.push(roman(rhs));
18752+
18753+ if let Some(defs) = option_default_values(pos) {
18754+ header.push(roman(format!(" {defs}")));
18755+ }
18756+
18757+ let mut body = vec![];
18758+ let mut arg_help_written = false;
18759+ if let Some(help) = option_help(pos) {
18760+ arg_help_written = true;
18761+ let mut help = help.to_string();
18762+ if !help.ends_with('.') {
18763+ help.push('.');
18764+ }
18765+ body.push(roman(help));
18766+ }
18767+
18768+ roff.control("TP", []);
18769+ roff.text(header);
18770+ roff.text(body);
18771+
18772+ if let Some(env) = option_environment(pos) {
18773+ roff.control("RS", []);
18774+ roff.text(env);
18775+ roff.control("RE", []);
18776+ }
18777+ // If possible options are available
18778+ if let Some((possible_values_text, with_help)) = get_possible_values(pos) {
18779+ if arg_help_written {
18780+ // It looks nice to have a separation between the help and the values
18781+ roff.text([Inline::LineBreak]);
18782+ }
18783+ if with_help {
18784+ roff.text([Inline::LineBreak, italic("Possible values:")]);
18785+
18786+ // Need to indent twice to get it to look right, because .TP heading indents,
18787+ // but that indent doesn't Carry over to the .IP for the
18788+ // bullets. The standard shift size is 7 for terminal devices
18789+ roff.control("RS", ["14"]);
18790+ for line in possible_values_text {
18791+ roff.control("IP", ["\\(bu", "2"]);
18792+ roff.text([roman(line)]);
18793+ }
18794+ roff.control("RE", []);
18795+ } else {
18796+ let possible_value_text: Vec<Inline> = vec![
18797+ Inline::LineBreak,
18798+ roman("["),
18799+ italic("possible values: "),
18800+ roman(possible_values_text.join(", ")),
18801+ roman("]"),
18802+ ];
18803+ roff.text(possible_value_text);
18804+ }
18805+ }
18806+ }
18807+
18808+ for opt in items.iter().filter(|a| !a.is_positional()) {
18809+ let mut header = match (opt.get_short(), opt.get_long()) {
18810+ (Some(short), Some(long)) => {
18811+ vec![short_option(short), roman(", "), long_option(long)]
18812+ }
18813+ (Some(short), None) => vec![short_option(short)],
18814+ (None, Some(long)) => vec![long_option(long)],
18815+ (None, None) => vec![],
18816+ };
18817+
18818+ if opt.get_action().takes_values() {
18819+ if let Some(value) = &opt.get_value_names() {
18820+ header.push(roman(" "));
18821+ header.push(italic(value.join(" ")));
18822+ }
18823+ }
18824+
18825+ if let Some(defs) = option_default_values(opt) {
18826+ header.push(roman(" "));
18827+ header.push(roman(defs));
18828+ }
18829+
18830+ let mut body = vec![];
18831+ let mut arg_help_written = false;
18832+ if let Some(help) = option_help(opt) {
18833+ arg_help_written = true;
18834+ let mut help = help.to_string();
18835+ if !help.as_str().ends_with('.') {
18836+ help.push('.');
18837+ }
18838+
18839+ body.push(roman(help));
18840+ }
18841+
18842+ roff.control("TP", []);
18843+ roff.text(header);
18844+ roff.text(body);
18845+
18846+ if let Some((possible_values_text, with_help)) = get_possible_values(opt) {
18847+ if arg_help_written {
18848+ // It looks nice to have a separation between the help and the values
18849+ roff.text([Inline::LineBreak, Inline::LineBreak]);
18850+ }
18851+ if with_help {
18852+ roff.text([Inline::LineBreak, italic("Possible values:")]);
18853+
18854+ // Need to indent twice to get it to look right, because .TP heading indents,
18855+ // but that indent doesn't Carry over to the .IP for the
18856+ // bullets. The standard shift size is 7 for terminal devices
18857+ roff.control("RS", ["14"]);
18858+ for line in possible_values_text {
18859+ roff.control("IP", ["\\(bu", "2"]);
18860+ roff.text([roman(line)]);
18861+ }
18862+ roff.control("RE", []);
18863+ } else {
18864+ let possible_value_text: Vec<Inline> = vec![
18865+ Inline::LineBreak,
18866+ roman("["),
18867+ italic("possible values: "),
18868+ roman(possible_values_text.join(", ")),
18869+ roman("]"),
18870+ ];
18871+ roff.text(possible_value_text);
18872+ }
18873+ }
18874+
18875+ if let Some(env) = option_environment(opt) {
18876+ roff.control("RS", []);
18877+ roff.text(env);
18878+ roff.control("RE", []);
18879+ }
18880+ }
18881+ }
18882+
18883+ fn option_markers(opt: &clap::Arg) -> (&'static str, &'static str) {
18884+ markers(opt.is_required_set())
18885+ }
18886+
18887+ fn option_markers_single(opt: &clap::Arg) -> (&'static str, &'static str) {
18888+ if opt.is_required_set() {
18889+ ("", "")
18890+ } else {
18891+ markers(opt.is_required_set())
18892+ }
18893+ }
18894+
18895+ fn markers(required: bool) -> (&'static str, &'static str) {
18896+ if required {
18897+ ("{", "}")
18898+ } else {
18899+ ("[", "]")
18900+ }
18901+ }
18902+
18903+ fn short_option(opt: char) -> Inline {
18904+ roman(format!("-{opt}"))
18905+ }
18906+
18907+ fn long_option(opt: &str) -> Inline {
18908+ roman(format!("--{opt}"))
18909+ }
18910+
18911+ fn option_help(opt: &clap::Arg) -> Option<&clap::builder::StyledStr> {
18912+ if !opt.is_hide_long_help_set() {
18913+ let long_help = opt.get_long_help();
18914+ if long_help.is_some() {
18915+ return long_help;
18916+ }
18917+ }
18918+ if !opt.is_hide_short_help_set() {
18919+ return opt.get_help();
18920+ }
18921+
18922+ None
18923+ }
18924+
18925+ fn option_environment(opt: &clap::Arg) -> Option<Vec<Inline>> {
18926+ if opt.is_hide_env_set() {
18927+ return None;
18928+ } else if let Some(env) = opt.get_env() {
18929+ return Some(vec![
18930+ roman("May also be specified with the "),
18931+ bold(env.to_string_lossy().into_owned()),
18932+ roman(" environment variable. "),
18933+ ]);
18934+ }
18935+
18936+ None
18937+ }
18938+
18939+ fn option_default_values(opt: &clap::Arg) -> Option<String> {
18940+ if opt.is_hide_default_value_set() || !opt.get_action().takes_values() {
18941+ return None;
18942+ } else if !opt.get_default_values().is_empty() {
18943+ let values = opt
18944+ .get_default_values()
18945+ .iter()
18946+ .map(|s| s.to_string_lossy())
18947+ .collect::<Vec<_>>()
18948+ .join(",");
18949+
18950+ return Some(format!("[default: {values}]"));
18951+ }
18952+
18953+ None
18954+ }
18955+
18956+ fn get_possible_values(arg: &clap::Arg) -> Option<(Vec<String>, bool)> {
18957+ let possibles = &arg.get_possible_values();
18958+ let possibles: Vec<&clap::builder::PossibleValue> =
18959+ possibles.iter().filter(|pos| !pos.is_hide_set()).collect();
18960+
18961+ if !(possibles.is_empty() || arg.is_hide_possible_values_set()) {
18962+ return Some(format_possible_values(&possibles));
18963+ }
18964+ None
18965+ }
18966+
18967+ fn format_possible_values(possibles: &Vec<&clap::builder::PossibleValue>) -> (Vec<String>, bool) {
18968+ let mut lines = vec![];
18969+ let with_help = possibles.iter().any(|p| p.get_help().is_some());
18970+ if with_help {
18971+ for value in possibles {
18972+ let val_name = value.get_name();
18973+ match value.get_help() {
18974+ Some(help) => lines.push(format!(
18975+ "{val_name}: {help}{period}",
18976+ period = if help.to_string().ends_with('.') {
18977+ ""
18978+ } else {
18979+ "."
18980+ }
18981+ )),
18982+ None => lines.push(val_name.to_string()),
18983+ }
18984+ }
18985+ } else {
18986+ lines.append(&mut possibles.iter().map(|p| p.get_name().to_string()).collect());
18987+ }
18988+ (lines, with_help)
18989+ }
18990 diff --git a/mailpot-cli/rustfmt.toml b/mailpot-cli/rustfmt.toml
18991new file mode 120000
18992index 0000000..39f97b0
18993--- /dev/null
18994+++ b/mailpot-cli/rustfmt.toml
18995 @@ -0,0 +1 @@
18996+ ../rustfmt.toml
18997\ No newline at end of file
18998 diff --git a/mailpot-cli/src/args.rs b/mailpot-cli/src/args.rs
18999new file mode 100644
19000index 0000000..8414783
19001--- /dev/null
19002+++ b/mailpot-cli/src/args.rs
19003 @@ -0,0 +1,571 @@
19004+ /*
19005+ * This file is part of mailpot
19006+ *
19007+ * Copyright 2020 - Manos Pitsidianakis
19008+ *
19009+ * This program is free software: you can redistribute it and/or modify
19010+ * it under the terms of the GNU Affero General Public License as
19011+ * published by the Free Software Foundation, either version 3 of the
19012+ * License, or (at your option) any later version.
19013+ *
19014+ * This program is distributed in the hope that it will be useful,
19015+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
19016+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19017+ * GNU Affero General Public License for more details.
19018+ *
19019+ * You should have received a copy of the GNU Affero General Public License
19020+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
19021+ */
19022+
19023+ pub use std::path::PathBuf;
19024+
19025+ pub use clap::{builder::TypedValueParser, Args, Parser, Subcommand};
19026+
19027+ #[derive(Debug, Parser)]
19028+ #[command(
19029+ name = "mpot",
19030+ about = "mailing list manager",
19031+ long_about = "Tool for mailpot mailing list management.",
19032+ before_long_help = "GNU Affero version 3 or later <https://www.gnu.org/licenses/>",
19033+ author,
19034+ version
19035+ )]
19036+ pub struct Opt {
19037+ /// Print logs.
19038+ #[arg(short, long)]
19039+ pub debug: bool,
19040+ /// Configuration file to use.
19041+ #[arg(short, long, value_parser)]
19042+ pub config: Option<PathBuf>,
19043+ #[command(subcommand)]
19044+ pub cmd: Command,
19045+ /// Silence all output.
19046+ #[arg(short, long)]
19047+ pub quiet: bool,
19048+ /// Verbose mode (-v, -vv, -vvv, etc).
19049+ #[arg(short, long, action = clap::ArgAction::Count)]
19050+ pub verbose: u8,
19051+ /// Debug log timestamp (sec, ms, ns, none).
19052+ #[arg(short, long)]
19053+ pub ts: Option<stderrlog::Timestamp>,
19054+ }
19055+
19056+ #[derive(Debug, Subcommand)]
19057+ pub enum Command {
19058+ /// Prints a sample config file to STDOUT.
19059+ ///
19060+ /// You can generate a new configuration file by writing the output to a
19061+ /// file, e.g: mpot sample-config --with-smtp > config.toml
19062+ SampleConfig {
19063+ /// Use an SMTP connection instead of a shell process.
19064+ #[arg(long)]
19065+ with_smtp: bool,
19066+ },
19067+ /// Dumps database data to STDOUT.
19068+ DumpDatabase,
19069+ /// Lists all registered mailing lists.
19070+ ListLists,
19071+ /// Mailing list management.
19072+ List {
19073+ /// Selects mailing list to operate on.
19074+ list_id: String,
19075+ #[command(subcommand)]
19076+ cmd: ListCommand,
19077+ },
19078+ /// Create new list.
19079+ CreateList {
19080+ /// List name.
19081+ #[arg(long)]
19082+ name: String,
19083+ /// List ID.
19084+ #[arg(long)]
19085+ id: String,
19086+ /// List e-mail address.
19087+ #[arg(long)]
19088+ address: String,
19089+ /// List description.
19090+ #[arg(long)]
19091+ description: Option<String>,
19092+ /// List archive URL.
19093+ #[arg(long)]
19094+ archive_url: Option<String>,
19095+ },
19096+ /// Post message from STDIN to list.
19097+ Post {
19098+ /// Show e-mail processing result without actually consuming it.
19099+ #[arg(long)]
19100+ dry_run: bool,
19101+ },
19102+ /// Flush outgoing e-mail queue.
19103+ FlushQueue {
19104+ /// Show e-mail processing result without actually consuming it.
19105+ #[arg(long)]
19106+ dry_run: bool,
19107+ },
19108+ /// Processed mail is stored in queues.
19109+ Queue {
19110+ #[arg(long, value_parser = QueueValueParser)]
19111+ queue: mailpot::queue::Queue,
19112+ #[command(subcommand)]
19113+ cmd: QueueCommand,
19114+ },
19115+ /// Import a maildir folder into an existing list.
19116+ ImportMaildir {
19117+ /// List-ID or primary key value.
19118+ list_id: String,
19119+ /// Path to a maildir mailbox.
19120+ /// Must contain {cur, tmp, new} folders.
19121+ #[arg(long, value_parser)]
19122+ maildir_path: PathBuf,
19123+ },
19124+ /// Update postfix maps and master.cf (probably needs root permissions).
19125+ UpdatePostfixConfig {
19126+ #[arg(short = 'p', long)]
19127+ /// Override location of master.cf file (default:
19128+ /// /etc/postfix/master.cf)
19129+ master_cf: Option<PathBuf>,
19130+ #[clap(flatten)]
19131+ config: PostfixConfig,
19132+ },
19133+ /// Print postfix maps and master.cf entry to STDOUT.
19134+ ///
19135+ /// Map output should be added to transport_maps and local_recipient_maps
19136+ /// parameters in postfix's main.cf. It must be saved in a plain text
19137+ /// file. To make postfix be able to read them, the postmap application
19138+ /// must be executed with the path to the map file as its sole argument.
19139+ ///
19140+ /// postmap /path/to/mylist_maps
19141+ ///
19142+ /// postmap is usually distributed along with the other postfix binaries.
19143+ ///
19144+ /// The master.cf entry must be manually appended to the master.cf file. See <https://www.postfix.org/master.5.html>.
19145+ PrintPostfixConfig {
19146+ #[clap(flatten)]
19147+ config: PostfixConfig,
19148+ },
19149+ /// All Accounts.
19150+ Accounts,
19151+ /// Account info.
19152+ AccountInfo {
19153+ /// Account address.
19154+ address: String,
19155+ },
19156+ /// Add account.
19157+ AddAccount {
19158+ /// E-mail address.
19159+ #[arg(long)]
19160+ address: String,
19161+ /// SSH public key for authentication.
19162+ #[arg(long)]
19163+ password: String,
19164+ /// Name.
19165+ #[arg(long)]
19166+ name: Option<String>,
19167+ /// Public key.
19168+ #[arg(long)]
19169+ public_key: Option<String>,
19170+ #[arg(long)]
19171+ /// Is account enabled.
19172+ enabled: Option<bool>,
19173+ },
19174+ /// Remove account.
19175+ RemoveAccount {
19176+ #[arg(long)]
19177+ /// E-mail address.
19178+ address: String,
19179+ },
19180+ /// Update account info.
19181+ UpdateAccount {
19182+ /// Address to edit.
19183+ address: String,
19184+ /// Public key for authentication.
19185+ #[arg(long)]
19186+ password: Option<String>,
19187+ /// Name.
19188+ #[arg(long)]
19189+ name: Option<Option<String>>,
19190+ /// Public key.
19191+ #[arg(long)]
19192+ public_key: Option<Option<String>>,
19193+ #[arg(long)]
19194+ /// Is account enabled.
19195+ enabled: Option<Option<bool>>,
19196+ },
19197+ /// Show and fix possible data mistakes or inconsistencies.
19198+ Repair {
19199+ /// Fix errors (default: false)
19200+ #[arg(long, default_value = "false")]
19201+ fix: bool,
19202+ /// Select all tests (default: false)
19203+ #[arg(long, default_value = "false")]
19204+ all: bool,
19205+ /// Post `datetime` column must have the Date: header value, in RFC2822
19206+ /// format.
19207+ #[arg(long, default_value = "false")]
19208+ datetime_header_value: bool,
19209+ /// Remove accounts that have no matching subscriptions.
19210+ #[arg(long, default_value = "false")]
19211+ remove_empty_accounts: bool,
19212+ /// Remove subscription requests that have been accepted.
19213+ #[arg(long, default_value = "false")]
19214+ remove_accepted_subscription_requests: bool,
19215+ /// Warn if a list has no owners.
19216+ #[arg(long, default_value = "false")]
19217+ warn_list_no_owner: bool,
19218+ },
19219+ }
19220+
19221+ /// Postfix config values.
19222+ #[derive(Debug, Args)]
19223+ pub struct PostfixConfig {
19224+ /// User that runs mailpot when postfix relays a message.
19225+ ///
19226+ /// Must not be the `postfix` user.
19227+ /// Must have permissions to access the database file and the data
19228+ /// directory.
19229+ #[arg(short, long)]
19230+ pub user: String,
19231+ /// Group that runs mailpot when postfix relays a message.
19232+ /// Optional.
19233+ #[arg(short, long)]
19234+ pub group: Option<String>,
19235+ /// The path to the mailpot binary postfix will execute.
19236+ #[arg(long)]
19237+ pub binary_path: PathBuf,
19238+ /// Limit the number of mailpot instances that can exist at the same time.
19239+ ///
19240+ /// Default is 1.
19241+ #[arg(long, default_value = "1")]
19242+ pub process_limit: Option<u64>,
19243+ /// The directory in which the map files are saved.
19244+ ///
19245+ /// Default is `data_path` from [`Configuration`](mailpot::Configuration).
19246+ #[arg(long)]
19247+ pub map_output_path: Option<PathBuf>,
19248+ /// The name of the postfix service name to use.
19249+ /// Default is `mailpot`.
19250+ ///
19251+ /// A postfix service is a daemon managed by the postfix process.
19252+ /// Each entry in the `master.cf` configuration file defines a single
19253+ /// service.
19254+ ///
19255+ /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
19256+ /// <https://www.postfix.org/master.5.html>.
19257+ #[arg(long)]
19258+ pub transport_name: Option<String>,
19259+ }
19260+
19261+ #[derive(Debug, Subcommand)]
19262+ pub enum QueueCommand {
19263+ /// List.
19264+ List,
19265+ /// Print entry in RFC5322 or JSON format.
19266+ Print {
19267+ /// index of entry.
19268+ #[arg(long)]
19269+ index: Vec<i64>,
19270+ },
19271+ /// Delete entry and print it in stdout.
19272+ Delete {
19273+ /// index of entry.
19274+ #[arg(long)]
19275+ index: Vec<i64>,
19276+ },
19277+ }
19278+
19279+ /// Subscription options.
19280+ #[derive(Debug, Args)]
19281+ pub struct SubscriptionOptions {
19282+ /// Name.
19283+ #[arg(long)]
19284+ pub name: Option<String>,
19285+ /// Send messages as digest.
19286+ #[arg(long, default_value = "false")]
19287+ pub digest: Option<bool>,
19288+ /// Hide message from list when posting.
19289+ #[arg(long, default_value = "false")]
19290+ pub hide_address: Option<bool>,
19291+ /// Hide message from list when posting.
19292+ #[arg(long, default_value = "false")]
19293+ /// E-mail address verification status.
19294+ pub verified: Option<bool>,
19295+ #[arg(long, default_value = "true")]
19296+ /// Receive confirmation email when posting.
19297+ pub receive_confirmation: Option<bool>,
19298+ #[arg(long, default_value = "true")]
19299+ /// Receive posts from list even if address exists in To or Cc header.
19300+ pub receive_duplicates: Option<bool>,
19301+ #[arg(long, default_value = "false")]
19302+ /// Receive own posts from list.
19303+ pub receive_own_posts: Option<bool>,
19304+ #[arg(long, default_value = "true")]
19305+ /// Is subscription enabled.
19306+ pub enabled: Option<bool>,
19307+ }
19308+
19309+ /// Account options.
19310+ #[derive(Debug, Args)]
19311+ pub struct AccountOptions {
19312+ /// Name.
19313+ #[arg(long)]
19314+ pub name: Option<String>,
19315+ /// Public key.
19316+ #[arg(long)]
19317+ pub public_key: Option<String>,
19318+ #[arg(long)]
19319+ /// Is account enabled.
19320+ pub enabled: Option<bool>,
19321+ }
19322+
19323+ #[derive(Debug, Subcommand)]
19324+ pub enum ListCommand {
19325+ /// List subscriptions of list.
19326+ Subscriptions,
19327+ /// List subscription requests.
19328+ SubscriptionRequests,
19329+ /// Add subscription to list.
19330+ AddSubscription {
19331+ /// E-mail address.
19332+ #[arg(long)]
19333+ address: String,
19334+ #[clap(flatten)]
19335+ subscription_options: SubscriptionOptions,
19336+ },
19337+ /// Remove subscription from list.
19338+ RemoveSubscription {
19339+ #[arg(long)]
19340+ /// E-mail address.
19341+ address: String,
19342+ },
19343+ /// Update subscription info.
19344+ UpdateSubscription {
19345+ /// Address to edit.
19346+ address: String,
19347+ #[clap(flatten)]
19348+ subscription_options: SubscriptionOptions,
19349+ },
19350+ /// Accept a subscription request by its primary key.
19351+ AcceptSubscriptionRequest {
19352+ /// The primary key of the request.
19353+ pk: i64,
19354+ /// Do not send confirmation e-mail.
19355+ #[arg(long, default_value = "false")]
19356+ do_not_send_confirmation: bool,
19357+ },
19358+ /// Send subscription confirmation manually.
19359+ SendConfirmationForSubscription {
19360+ /// The primary key of the subscription.
19361+ pk: i64,
19362+ },
19363+ /// Add a new post policy.
19364+ AddPostPolicy {
19365+ #[arg(long)]
19366+ /// Only list owners can post.
19367+ announce_only: bool,
19368+ #[arg(long)]
19369+ /// Only subscriptions can post.
19370+ subscription_only: bool,
19371+ #[arg(long)]
19372+ /// Subscriptions can post.
19373+ /// Other posts must be approved by list owners.
19374+ approval_needed: bool,
19375+ #[arg(long)]
19376+ /// Anyone can post without restrictions.
19377+ open: bool,
19378+ #[arg(long)]
19379+ /// Allow posts, but handle it manually.
19380+ custom: bool,
19381+ },
19382+ // Remove post policy.
19383+ RemovePostPolicy {
19384+ #[arg(long)]
19385+ /// Post policy primary key.
19386+ pk: i64,
19387+ },
19388+ /// Add subscription policy to list.
19389+ AddSubscriptionPolicy {
19390+ #[arg(long)]
19391+ /// Send confirmation e-mail when subscription is finalized.
19392+ send_confirmation: bool,
19393+ #[arg(long)]
19394+ /// Anyone can subscribe without restrictions.
19395+ open: bool,
19396+ #[arg(long)]
19397+ /// Only list owners can manually add subscriptions.
19398+ manual: bool,
19399+ #[arg(long)]
19400+ /// Anyone can request to subscribe.
19401+ request: bool,
19402+ #[arg(long)]
19403+ /// Allow subscriptions, but handle it manually.
19404+ custom: bool,
19405+ },
19406+ RemoveSubscriptionPolicy {
19407+ #[arg(long)]
19408+ /// Subscription policy primary key.
19409+ pk: i64,
19410+ },
19411+ /// Add list owner to list.
19412+ AddListOwner {
19413+ #[arg(long)]
19414+ address: String,
19415+ #[arg(long)]
19416+ name: Option<String>,
19417+ },
19418+ RemoveListOwner {
19419+ #[arg(long)]
19420+ /// List owner primary key.
19421+ pk: i64,
19422+ },
19423+ /// Alias for update-subscription --enabled true.
19424+ EnableSubscription {
19425+ /// Subscription address.
19426+ address: String,
19427+ },
19428+ /// Alias for update-subscription --enabled false.
19429+ DisableSubscription {
19430+ /// Subscription address.
19431+ address: String,
19432+ },
19433+ /// Update mailing list details.
19434+ Update {
19435+ /// New list name.
19436+ #[arg(long)]
19437+ name: Option<String>,
19438+ /// New List-ID.
19439+ #[arg(long)]
19440+ id: Option<String>,
19441+ /// New list address.
19442+ #[arg(long)]
19443+ address: Option<String>,
19444+ /// New list description.
19445+ #[arg(long)]
19446+ description: Option<String>,
19447+ /// New list archive URL.
19448+ #[arg(long)]
19449+ archive_url: Option<String>,
19450+ /// New owner address local part.
19451+ /// If empty, it defaults to '+owner'.
19452+ #[arg(long)]
19453+ owner_local_part: Option<String>,
19454+ /// New request address local part.
19455+ /// If empty, it defaults to '+request'.
19456+ #[arg(long)]
19457+ request_local_part: Option<String>,
19458+ /// Require verification of e-mails for new subscriptions.
19459+ ///
19460+ /// Subscriptions that are initiated from the subscription's address are
19461+ /// verified automatically.
19462+ #[arg(long)]
19463+ verify: Option<bool>,
19464+ /// Public visibility of list.
19465+ ///
19466+ /// If hidden, the list will not show up in public APIs unless
19467+ /// requests to it won't work.
19468+ #[arg(long)]
19469+ hidden: Option<bool>,
19470+ /// Enable or disable the list's functionality.
19471+ ///
19472+ /// If not enabled, the list will continue to show up in the database
19473+ /// but e-mails and requests to it won't work.
19474+ #[arg(long)]
19475+ enabled: Option<bool>,
19476+ },
19477+ /// Show mailing list health status.
19478+ Health,
19479+ /// Show mailing list info.
19480+ Info,
19481+ /// Import members in a local list from a remote mailman3 REST API instance.
19482+ ///
19483+ /// To find the id of the remote list, you can check URL/lists.
19484+ /// Example with curl:
19485+ ///
19486+ /// curl --anyauth -u admin:pass "http://localhost:9001/3.0/lists"
19487+ ///
19488+ /// If you're trying to import an entire list, create it first and then
19489+ /// import its users with this command.
19490+ ///
19491+ /// Example:
19492+ /// mpot -c conf.toml list list-general import-members --url "http://localhost:9001/3.0/" --username admin --password password --list-id list-general.example.com --skip-owners --dry-run
19493+ ImportMembers {
19494+ #[arg(long)]
19495+ /// REST HTTP endpoint e.g. http://localhost:9001/3.0/
19496+ url: String,
19497+ #[arg(long)]
19498+ /// REST HTTP Basic Authentication username.
19499+ username: String,
19500+ #[arg(long)]
19501+ /// REST HTTP Basic Authentication password.
19502+ password: String,
19503+ #[arg(long)]
19504+ /// List ID of remote list to query.
19505+ list_id: String,
19506+ /// Show what would be inserted without performing any changes.
19507+ #[arg(long)]
19508+ dry_run: bool,
19509+ /// Don't import list owners.
19510+ #[arg(long)]
19511+ skip_owners: bool,
19512+ },
19513+ }
19514+
19515+ #[derive(Clone, Copy, Debug)]
19516+ pub struct QueueValueParser;
19517+
19518+ impl QueueValueParser {
19519+ pub fn new() -> Self {
19520+ Self
19521+ }
19522+ }
19523+
19524+ impl TypedValueParser for QueueValueParser {
19525+ type Value = mailpot::queue::Queue;
19526+
19527+ fn parse_ref(
19528+ &self,
19529+ cmd: &clap::Command,
19530+ arg: Option<&clap::Arg>,
19531+ value: &std::ffi::OsStr,
19532+ ) -> std::result::Result<Self::Value, clap::Error> {
19533+ TypedValueParser::parse(self, cmd, arg, value.to_owned())
19534+ }
19535+
19536+ fn parse(
19537+ &self,
19538+ cmd: &clap::Command,
19539+ _arg: Option<&clap::Arg>,
19540+ value: std::ffi::OsString,
19541+ ) -> std::result::Result<Self::Value, clap::Error> {
19542+ use std::str::FromStr;
19543+
19544+ use clap::error::ErrorKind;
19545+
19546+ if value.is_empty() {
19547+ return Err(cmd.clone().error(
19548+ ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
19549+ "queue value required",
19550+ ));
19551+ }
19552+ Self::Value::from_str(value.to_str().ok_or_else(|| {
19553+ cmd.clone().error(
19554+ ErrorKind::InvalidValue,
19555+ "Queue value is not an UTF-8 string",
19556+ )
19557+ })?)
19558+ .map_err(|err| cmd.clone().error(ErrorKind::InvalidValue, err))
19559+ }
19560+
19561+ fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue>>> {
19562+ Some(Box::new(
19563+ mailpot::queue::Queue::possible_values()
19564+ .iter()
19565+ .map(clap::builder::PossibleValue::new),
19566+ ))
19567+ }
19568+ }
19569+
19570+ impl Default for QueueValueParser {
19571+ fn default() -> Self {
19572+ Self::new()
19573+ }
19574+ }
19575 diff --git a/mailpot-cli/src/commands.rs b/mailpot-cli/src/commands.rs
19576new file mode 100644
19577index 0000000..d3f8be5
19578--- /dev/null
19579+++ b/mailpot-cli/src/commands.rs
19580 @@ -0,0 +1,1093 @@
19581+ /*
19582+ * This file is part of mailpot
19583+ *
19584+ * Copyright 2020 - Manos Pitsidianakis
19585+ *
19586+ * This program is free software: you can redistribute it and/or modify
19587+ * it under the terms of the GNU Affero General Public License as
19588+ * published by the Free Software Foundation, either version 3 of the
19589+ * License, or (at your option) any later version.
19590+ *
19591+ * This program is distributed in the hope that it will be useful,
19592+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
19593+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19594+ * GNU Affero General Public License for more details.
19595+ *
19596+ * You should have received a copy of the GNU Affero General Public License
19597+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
19598+ */
19599+
19600+ use std::{
19601+ collections::hash_map::DefaultHasher,
19602+ hash::{Hash, Hasher},
19603+ io::{Read, Write},
19604+ path::{Path, PathBuf},
19605+ process::Stdio,
19606+ };
19607+
19608+ use mailpot::{
19609+ melib,
19610+ melib::{maildir::MaildirPathTrait, smol, Envelope, EnvelopeHash},
19611+ models::{changesets::*, *},
19612+ queue::{Queue, QueueEntry},
19613+ transaction::TransactionBehavior,
19614+ Connection, Context, Error, ErrorKind, Result,
19615+ };
19616+
19617+ use crate::{lints::*, *};
19618+
19619+ macro_rules! list {
19620+ ($db:ident, $list_id:expr) => {{
19621+ $db.list_by_id(&$list_id)?.or_else(|| {
19622+ $list_id
19623+ .parse::<i64>()
19624+ .ok()
19625+ .map(|pk| $db.list(pk).ok())
19626+ .flatten()
19627+ .flatten()
19628+ })
19629+ }};
19630+ }
19631+
19632+ macro_rules! string_opts {
19633+ ($field:ident) => {
19634+ if $field.as_deref().map(str::is_empty).unwrap_or(false) {
19635+ None
19636+ } else {
19637+ Some($field)
19638+ }
19639+ };
19640+ }
19641+
19642+ pub fn dump_database(db: &mut Connection) -> Result<()> {
19643+ let lists = db.lists()?;
19644+ let mut stdout = std::io::stdout();
19645+ serde_json::to_writer_pretty(&mut stdout, &lists)?;
19646+ for l in &lists {
19647+ serde_json::to_writer_pretty(
19648+ &mut stdout,
19649+ &db.list_subscriptions(l.pk)
19650+ .context("Could not retrieve list subscriptions.")?,
19651+ )?;
19652+ }
19653+ Ok(())
19654+ }
19655+
19656+ pub fn list_lists(db: &mut Connection) -> Result<()> {
19657+ let lists = db.lists().context("Could not retrieve lists.")?;
19658+ if lists.is_empty() {
19659+ println!("No lists found.");
19660+ } else {
19661+ for l in lists {
19662+ println!("- {} {:?}", l.id, l);
19663+ let list_owners = db
19664+ .list_owners(l.pk)
19665+ .context("Could not retrieve list owners.")?;
19666+ if list_owners.is_empty() {
19667+ println!("\tList owners: None");
19668+ } else {
19669+ println!("\tList owners:");
19670+ for o in list_owners {
19671+ println!("\t- {}", o);
19672+ }
19673+ }
19674+ if let Some(s) = db
19675+ .list_post_policy(l.pk)
19676+ .context("Could not retrieve list post policy.")?
19677+ {
19678+ println!("\tPost policy: {}", s);
19679+ } else {
19680+ println!("\tPost policy: None");
19681+ }
19682+ if let Some(s) = db
19683+ .list_subscription_policy(l.pk)
19684+ .context("Could not retrieve list subscription policy.")?
19685+ {
19686+ println!("\tSubscription policy: {}", s);
19687+ } else {
19688+ println!("\tSubscription policy: None");
19689+ }
19690+ println!();
19691+ }
19692+ }
19693+ Ok(())
19694+ }
19695+
19696+ pub fn list(db: &mut Connection, list_id: &str, cmd: ListCommand, quiet: bool) -> Result<()> {
19697+ let list = match list!(db, list_id) {
19698+ Some(v) => v,
19699+ None => {
19700+ return Err(format!("No list with id or pk {} was found", list_id).into());
19701+ }
19702+ };
19703+ use ListCommand::*;
19704+ match cmd {
19705+ Subscriptions => {
19706+ let subscriptions = db.list_subscriptions(list.pk)?;
19707+ if subscriptions.is_empty() {
19708+ if !quiet {
19709+ println!("No subscriptions found.");
19710+ }
19711+ } else {
19712+ if !quiet {
19713+ println!("Subscriptions of list {}", list.id);
19714+ }
19715+ for l in subscriptions {
19716+ println!("- {}", &l);
19717+ }
19718+ }
19719+ }
19720+ AddSubscription {
19721+ address,
19722+ subscription_options:
19723+ SubscriptionOptions {
19724+ name,
19725+ digest,
19726+ hide_address,
19727+ receive_duplicates,
19728+ receive_own_posts,
19729+ receive_confirmation,
19730+ enabled,
19731+ verified,
19732+ },
19733+ } => {
19734+ db.add_subscription(
19735+ list.pk,
19736+ ListSubscription {
19737+ pk: 0,
19738+ list: list.pk,
19739+ address,
19740+ account: None,
19741+ name,
19742+ digest: digest.unwrap_or(false),
19743+ hide_address: hide_address.unwrap_or(false),
19744+ receive_confirmation: receive_confirmation.unwrap_or(true),
19745+ receive_duplicates: receive_duplicates.unwrap_or(true),
19746+ receive_own_posts: receive_own_posts.unwrap_or(false),
19747+ enabled: enabled.unwrap_or(true),
19748+ verified: verified.unwrap_or(false),
19749+ },
19750+ )?;
19751+ }
19752+ RemoveSubscription { address } => {
19753+ let mut input = String::new();
19754+ loop {
19755+ println!(
19756+ "Are you sure you want to remove subscription of {} from list {}? [Yy/n]",
19757+ address, list
19758+ );
19759+ input.clear();
19760+ std::io::stdin().read_line(&mut input)?;
19761+ if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
19762+ break;
19763+ } else if input.trim() == "n" {
19764+ return Ok(());
19765+ }
19766+ }
19767+
19768+ db.remove_subscription(list.pk, &address)?;
19769+ }
19770+ Health => {
19771+ if !quiet {
19772+ println!("{} health:", list);
19773+ }
19774+ let list_owners = db
19775+ .list_owners(list.pk)
19776+ .context("Could not retrieve list owners.")?;
19777+ let post_policy = db
19778+ .list_post_policy(list.pk)
19779+ .context("Could not retrieve list post policy.")?;
19780+ let subscription_policy = db
19781+ .list_subscription_policy(list.pk)
19782+ .context("Could not retrieve list subscription policy.")?;
19783+ if list_owners.is_empty() {
19784+ println!("\tList has no owners: you should add at least one.");
19785+ } else {
19786+ for owner in list_owners {
19787+ println!("\tList owner: {}.", owner);
19788+ }
19789+ }
19790+ if let Some(p) = post_policy {
19791+ println!("\tList has post policy: {p}.");
19792+ } else {
19793+ println!("\tList has no post policy: you should add one.");
19794+ }
19795+ if let Some(p) = subscription_policy {
19796+ println!("\tList has subscription policy: {p}.");
19797+ } else {
19798+ println!("\tList has no subscription policy: you should add one.");
19799+ }
19800+ }
19801+ Info => {
19802+ println!("{} info:", list);
19803+ let list_owners = db
19804+ .list_owners(list.pk)
19805+ .context("Could not retrieve list owners.")?;
19806+ let post_policy = db
19807+ .list_post_policy(list.pk)
19808+ .context("Could not retrieve list post policy.")?;
19809+ let subscription_policy = db
19810+ .list_subscription_policy(list.pk)
19811+ .context("Could not retrieve list subscription policy.")?;
19812+ let subscriptions = db
19813+ .list_subscriptions(list.pk)
19814+ .context("Could not retrieve list subscriptions.")?;
19815+ if subscriptions.is_empty() {
19816+ println!("No subscriptions.");
19817+ } else if subscriptions.len() == 1 {
19818+ println!("1 subscription.");
19819+ } else {
19820+ println!("{} subscriptions.", subscriptions.len());
19821+ }
19822+ if list_owners.is_empty() {
19823+ println!("List owners: None");
19824+ } else {
19825+ println!("List owners:");
19826+ for o in list_owners {
19827+ println!("\t- {}", o);
19828+ }
19829+ }
19830+ if let Some(s) = post_policy {
19831+ println!("Post policy: {s}");
19832+ } else {
19833+ println!("Post policy: None");
19834+ }
19835+ if let Some(s) = subscription_policy {
19836+ println!("Subscription policy: {s}");
19837+ } else {
19838+ println!("Subscription policy: None");
19839+ }
19840+ }
19841+ UpdateSubscription {
19842+ address,
19843+ subscription_options:
19844+ SubscriptionOptions {
19845+ name,
19846+ digest,
19847+ hide_address,
19848+ receive_duplicates,
19849+ receive_own_posts,
19850+ receive_confirmation,
19851+ enabled,
19852+ verified,
19853+ },
19854+ } => {
19855+ let name = if name
19856+ .as_ref()
19857+ .map(|s: &String| s.is_empty())
19858+ .unwrap_or(false)
19859+ {
19860+ None
19861+ } else {
19862+ Some(name)
19863+ };
19864+ let changeset = ListSubscriptionChangeset {
19865+ list: list.pk,
19866+ address,
19867+ account: None,
19868+ name,
19869+ digest,
19870+ verified,
19871+ hide_address,
19872+ receive_duplicates,
19873+ receive_own_posts,
19874+ receive_confirmation,
19875+ enabled,
19876+ };
19877+ db.update_subscription(changeset)?;
19878+ }
19879+ AddPostPolicy {
19880+ announce_only,
19881+ subscription_only,
19882+ approval_needed,
19883+ open,
19884+ custom,
19885+ } => {
19886+ let policy = PostPolicy {
19887+ pk: 0,
19888+ list: list.pk,
19889+ announce_only,
19890+ subscription_only,
19891+ approval_needed,
19892+ open,
19893+ custom,
19894+ };
19895+ let new_val = db.set_list_post_policy(policy)?;
19896+ println!("Added new policy with pk = {}", new_val.pk());
19897+ }
19898+ RemovePostPolicy { pk } => {
19899+ db.remove_list_post_policy(list.pk, pk)?;
19900+ println!("Removed policy with pk = {}", pk);
19901+ }
19902+ AddSubscriptionPolicy {
19903+ send_confirmation,
19904+ open,
19905+ manual,
19906+ request,
19907+ custom,
19908+ } => {
19909+ let policy = SubscriptionPolicy {
19910+ pk: 0,
19911+ list: list.pk,
19912+ send_confirmation,
19913+ open,
19914+ manual,
19915+ request,
19916+ custom,
19917+ };
19918+ let new_val = db.set_list_subscription_policy(policy)?;
19919+ println!("Added new subscribe policy with pk = {}", new_val.pk());
19920+ }
19921+ RemoveSubscriptionPolicy { pk } => {
19922+ db.remove_list_subscription_policy(list.pk, pk)?;
19923+ println!("Removed subscribe policy with pk = {}", pk);
19924+ }
19925+ AddListOwner { address, name } => {
19926+ let list_owner = ListOwner {
19927+ pk: 0,
19928+ list: list.pk,
19929+ address,
19930+ name,
19931+ };
19932+ let new_val = db.add_list_owner(list_owner)?;
19933+ println!("Added new list owner {}", new_val);
19934+ }
19935+ RemoveListOwner { pk } => {
19936+ db.remove_list_owner(list.pk, pk)?;
19937+ println!("Removed list owner with pk = {}", pk);
19938+ }
19939+ EnableSubscription { address } => {
19940+ let changeset = ListSubscriptionChangeset {
19941+ list: list.pk,
19942+ address,
19943+ account: None,
19944+ name: None,
19945+ digest: None,
19946+ verified: None,
19947+ enabled: Some(true),
19948+ hide_address: None,
19949+ receive_duplicates: None,
19950+ receive_own_posts: None,
19951+ receive_confirmation: None,
19952+ };
19953+ db.update_subscription(changeset)?;
19954+ }
19955+ DisableSubscription { address } => {
19956+ let changeset = ListSubscriptionChangeset {
19957+ list: list.pk,
19958+ address,
19959+ account: None,
19960+ name: None,
19961+ digest: None,
19962+ enabled: Some(false),
19963+ verified: None,
19964+ hide_address: None,
19965+ receive_duplicates: None,
19966+ receive_own_posts: None,
19967+ receive_confirmation: None,
19968+ };
19969+ db.update_subscription(changeset)?;
19970+ }
19971+ Update {
19972+ name,
19973+ id,
19974+ address,
19975+ description,
19976+ archive_url,
19977+ owner_local_part,
19978+ request_local_part,
19979+ verify,
19980+ hidden,
19981+ enabled,
19982+ } => {
19983+ let description = string_opts!(description);
19984+ let archive_url = string_opts!(archive_url);
19985+ let owner_local_part = string_opts!(owner_local_part);
19986+ let request_local_part = string_opts!(request_local_part);
19987+ let changeset = MailingListChangeset {
19988+ pk: list.pk,
19989+ name,
19990+ id,
19991+ address,
19992+ description,
19993+ archive_url,
19994+ owner_local_part,
19995+ request_local_part,
19996+ verify,
19997+ hidden,
19998+ enabled,
19999+ };
20000+ db.update_list(changeset)?;
20001+ }
20002+ ImportMembers {
20003+ url,
20004+ username,
20005+ password,
20006+ list_id,
20007+ dry_run,
20008+ skip_owners,
20009+ } => {
20010+ let conn = import::Mailman3Connection::new(&url, &username, &password).unwrap();
20011+ if dry_run {
20012+ let entries = conn.users(&list_id).unwrap();
20013+ println!("{} result(s)", entries.len());
20014+ for e in entries {
20015+ println!(
20016+ "{}{}<{}>",
20017+ if let Some(n) = e.display_name() {
20018+ n
20019+ } else {
20020+ ""
20021+ },
20022+ if e.display_name().is_none() { "" } else { " " },
20023+ e.email()
20024+ );
20025+ }
20026+ if !skip_owners {
20027+ let entries = conn.owners(&list_id).unwrap();
20028+ println!("\nOwners: {} result(s)", entries.len());
20029+ for e in entries {
20030+ println!(
20031+ "{}{}<{}>",
20032+ if let Some(n) = e.display_name() {
20033+ n
20034+ } else {
20035+ ""
20036+ },
20037+ if e.display_name().is_none() { "" } else { " " },
20038+ e.email()
20039+ );
20040+ }
20041+ }
20042+ } else {
20043+ let entries = conn.users(&list_id).unwrap();
20044+ let tx = db.transaction(Default::default()).unwrap();
20045+ for sub in entries.into_iter().map(|e| e.into_subscription(list.pk)) {
20046+ tx.add_subscription(list.pk, sub)?;
20047+ }
20048+ if !skip_owners {
20049+ let entries = conn.owners(&list_id).unwrap();
20050+ for sub in entries.into_iter().map(|e| e.into_owner(list.pk)) {
20051+ tx.add_list_owner(sub)?;
20052+ }
20053+ }
20054+ tx.commit()?;
20055+ }
20056+ }
20057+ SubscriptionRequests => {
20058+ let subscriptions = db.list_subscription_requests(list.pk)?;
20059+ if subscriptions.is_empty() {
20060+ println!("No subscription requests found.");
20061+ } else {
20062+ println!("Subscription requests of list {}", list.id);
20063+ for l in subscriptions {
20064+ println!("- {}", &l);
20065+ }
20066+ }
20067+ }
20068+ AcceptSubscriptionRequest {
20069+ pk,
20070+ do_not_send_confirmation,
20071+ } => match db.accept_candidate_subscription(pk) {
20072+ Ok(subscription) => {
20073+ println!("Added: {subscription:#?}");
20074+ if !do_not_send_confirmation {
20075+ if let Err(err) = db
20076+ .list(subscription.list)
20077+ .and_then(|v| match v {
20078+ Some(v) => Ok(v),
20079+ None => Err(format!(
20080+ "No list with id or pk {} was found",
20081+ subscription.list
20082+ )
20083+ .into()),
20084+ })
20085+ .and_then(|list| {
20086+ db.send_subscription_confirmation(&list, &subscription.address())
20087+ })
20088+ {
20089+ eprintln!("Could not send subscription confirmation!");
20090+ return Err(err);
20091+ }
20092+ println!("Sent confirmation e-mail to {}", subscription.address());
20093+ } else {
20094+ println!(
20095+ "Did not sent confirmation e-mail to {}. You can do it manually with the \
20096+ appropriate command.",
20097+ subscription.address()
20098+ );
20099+ }
20100+ }
20101+ Err(err) => {
20102+ eprintln!("Could not accept subscription request!");
20103+ return Err(err);
20104+ }
20105+ },
20106+ SendConfirmationForSubscription { pk } => {
20107+ let req = match db.candidate_subscription(pk) {
20108+ Ok(req) => req,
20109+ Err(err) => {
20110+ eprintln!("Could not find subscription request by that pk!");
20111+
20112+ return Err(err);
20113+ }
20114+ };
20115+ log::info!("Found {:#?}", req);
20116+ if req.accepted.is_none() {
20117+ return Err("Request has not been accepted!".into());
20118+ }
20119+ if let Err(err) = db
20120+ .list(req.list)
20121+ .and_then(|v| match v {
20122+ Some(v) => Ok(v),
20123+ None => Err(format!("No list with id or pk {} was found", req.list).into()),
20124+ })
20125+ .and_then(|list| db.send_subscription_confirmation(&list, &req.address()))
20126+ {
20127+ eprintln!("Could not send subscription request confirmation!");
20128+ return Err(err);
20129+ }
20130+
20131+ println!("Sent confirmation e-mail to {}", req.address());
20132+ }
20133+ }
20134+ Ok(())
20135+ }
20136+
20137+ pub fn create_list(
20138+ db: &mut Connection,
20139+ name: String,
20140+ id: String,
20141+ address: String,
20142+ description: Option<String>,
20143+ archive_url: Option<String>,
20144+ quiet: bool,
20145+ ) -> Result<()> {
20146+ let new = db.create_list(MailingList {
20147+ pk: 0,
20148+ name,
20149+ id,
20150+ description,
20151+ topics: vec![],
20152+ address,
20153+ archive_url,
20154+ })?;
20155+ log::trace!("created new list {:#?}", new);
20156+ if !quiet {
20157+ println!(
20158+ "Created new list {:?} with primary key {}",
20159+ new.id,
20160+ new.pk()
20161+ );
20162+ }
20163+ Ok(())
20164+ }
20165+
20166+ pub fn post(db: &mut Connection, dry_run: bool, debug: bool) -> Result<()> {
20167+ if debug {
20168+ println!("Post dry_run = {:?}", dry_run);
20169+ }
20170+
20171+ let tx = db
20172+ .transaction(TransactionBehavior::Exclusive)
20173+ .context("Could not open Exclusive transaction in database.")?;
20174+ let mut input = String::new();
20175+ std::io::stdin()
20176+ .read_to_string(&mut input)
20177+ .context("Could not read from stdin")?;
20178+ match Envelope::from_bytes(input.as_bytes(), None) {
20179+ Ok(env) => {
20180+ if debug {
20181+ eprintln!("Parsed envelope is:\n{:?}", &env);
20182+ }
20183+ tx.post(&env, input.as_bytes(), dry_run)?;
20184+ }
20185+ Err(err) if input.trim().is_empty() => {
20186+ eprintln!("Empty input, abort.");
20187+ return Err(err.into());
20188+ }
20189+ Err(err) => {
20190+ eprintln!("Could not parse message: {}", err);
20191+ let p = tx.conf().save_message(input)?;
20192+ eprintln!("Message saved at {}", p.display());
20193+ return Err(err.into());
20194+ }
20195+ }
20196+ tx.commit()
20197+ }
20198+
20199+ pub fn flush_queue(db: &mut Connection, dry_run: bool, verbose: u8, debug: bool) -> Result<()> {
20200+ let tx = db
20201+ .transaction(TransactionBehavior::Exclusive)
20202+ .context("Could not open Exclusive transaction in database.")?;
20203+ let messages = tx.delete_from_queue(mailpot::queue::Queue::Out, vec![])?;
20204+ if verbose > 0 || debug {
20205+ println!("Queue out has {} messages.", messages.len());
20206+ }
20207+
20208+ let mut failures = Vec::with_capacity(messages.len());
20209+
20210+ let send_mail = tx.conf().send_mail.clone();
20211+ match send_mail {
20212+ mailpot::SendMail::ShellCommand(cmd) => {
20213+ fn submit(cmd: &str, msg: &QueueEntry, dry_run: bool) -> Result<()> {
20214+ if dry_run {
20215+ return Ok(());
20216+ }
20217+ let mut child = std::process::Command::new("sh")
20218+ .arg("-c")
20219+ .arg(cmd)
20220+ .env("TO_ADDRESS", msg.to_addresses.clone())
20221+ .stdout(Stdio::piped())
20222+ .stdin(Stdio::piped())
20223+ .stderr(Stdio::piped())
20224+ .spawn()
20225+ .context("sh command failed to start")?;
20226+ let mut stdin = child
20227+ .stdin
20228+ .take()
20229+ .ok_or_else(|| Error::from("Failed to open stdin"))?;
20230+
20231+ let builder = std::thread::Builder::new();
20232+
20233+ std::thread::scope(|s| {
20234+ let handler = builder
20235+ .spawn_scoped(s, move || {
20236+ stdin
20237+ .write_all(&msg.message)
20238+ .expect("Failed to write to stdin");
20239+ })
20240+ .context(
20241+ "Could not spawn IPC communication thread for SMTP ShellCommand \
20242+ process",
20243+ )?;
20244+
20245+ handler.join().map_err(|_| {
20246+ ErrorKind::External(mailpot::anyhow::anyhow!(
20247+ "Could not join with IPC communication thread for SMTP ShellCommand \
20248+ process"
20249+ ))
20250+ })?;
20251+ let result = child.wait_with_output()?;
20252+ if !result.status.success() {
20253+ return Err(Error::new_external(format!(
20254+ "{} proccess failed with exit code: {:?}\n{}",
20255+ cmd,
20256+ result.status.code(),
20257+ String::from_utf8(result.stderr).unwrap()
20258+ )));
20259+ }
20260+ Ok::<(), Error>(())
20261+ })?;
20262+ Ok(())
20263+ }
20264+ for msg in messages {
20265+ if let Err(err) = submit(&cmd, &msg, dry_run) {
20266+ if verbose > 0 || debug {
20267+ eprintln!("Message {msg:?} failed with: {err}.");
20268+ }
20269+ failures.push((err, msg));
20270+ } else if verbose > 0 || debug {
20271+ eprintln!("Submitted message {}", msg.message_id);
20272+ }
20273+ }
20274+ }
20275+ mailpot::SendMail::Smtp(_) => {
20276+ let conn_future = tx.new_smtp_connection()?;
20277+ failures = smol::future::block_on(smol::spawn(async move {
20278+ let mut conn = conn_future.await?;
20279+ for msg in messages {
20280+ if let Err(err) = Connection::submit(&mut conn, &msg, dry_run).await {
20281+ failures.push((err, msg));
20282+ }
20283+ }
20284+ Ok::<_, Error>(failures)
20285+ }))?;
20286+ }
20287+ }
20288+
20289+ for (err, mut msg) in failures {
20290+ log::error!("Message {msg:?} failed with: {err}. Inserting to Deferred queue.");
20291+
20292+ msg.queue = mailpot::queue::Queue::Deferred;
20293+ tx.insert_to_queue(msg)?;
20294+ }
20295+
20296+ if !dry_run {
20297+ tx.commit()?;
20298+ }
20299+ Ok(())
20300+ }
20301+
20302+ pub fn queue_(db: &mut Connection, queue: Queue, cmd: QueueCommand, quiet: bool) -> Result<()> {
20303+ match cmd {
20304+ QueueCommand::List => {
20305+ let entries = db.queue(queue)?;
20306+ if entries.is_empty() {
20307+ if !quiet {
20308+ println!("Queue {queue} is empty.");
20309+ }
20310+ } else {
20311+ for e in entries {
20312+ println!(
20313+ "- {} {} {} {} {}",
20314+ e.pk, e.datetime, e.from_address, e.to_addresses, e.subject
20315+ );
20316+ }
20317+ }
20318+ }
20319+ QueueCommand::Print { index } => {
20320+ let mut entries = db.queue(queue)?;
20321+ if !index.is_empty() {
20322+ entries.retain(|el| index.contains(&el.pk()));
20323+ }
20324+ if entries.is_empty() {
20325+ if !quiet {
20326+ println!("Queue {queue} is empty.");
20327+ }
20328+ } else {
20329+ for e in entries {
20330+ println!("{e:?}");
20331+ }
20332+ }
20333+ }
20334+ QueueCommand::Delete { index } => {
20335+ let mut entries = db.queue(queue)?;
20336+ if !index.is_empty() {
20337+ entries.retain(|el| index.contains(&el.pk()));
20338+ }
20339+ if entries.is_empty() {
20340+ if !quiet {
20341+ println!("Queue {queue} is empty.");
20342+ }
20343+ } else {
20344+ if !quiet {
20345+ println!("Deleting queue {queue} elements {:?}", &index);
20346+ }
20347+ db.delete_from_queue(queue, index)?;
20348+ if !quiet {
20349+ for e in entries {
20350+ println!("{e:?}");
20351+ }
20352+ }
20353+ }
20354+ }
20355+ }
20356+ Ok(())
20357+ }
20358+
20359+ pub fn import_maildir(
20360+ db: &mut Connection,
20361+ list_id: &str,
20362+ mut maildir_path: PathBuf,
20363+ quiet: bool,
20364+ debug: bool,
20365+ verbose: u8,
20366+ ) -> Result<()> {
20367+ let list = match list!(db, list_id) {
20368+ Some(v) => v,
20369+ None => {
20370+ return Err(format!("No list with id or pk {} was found", list_id).into());
20371+ }
20372+ };
20373+ if !maildir_path.is_absolute() {
20374+ maildir_path = std::env::current_dir()
20375+ .context("could not detect current directory")?
20376+ .join(&maildir_path);
20377+ }
20378+
20379+ fn get_file_hash(file: &std::path::Path) -> EnvelopeHash {
20380+ let mut hasher = DefaultHasher::default();
20381+ file.hash(&mut hasher);
20382+ EnvelopeHash(hasher.finish())
20383+ }
20384+ let mut buf = Vec::with_capacity(4096);
20385+ let files = melib::maildir::MaildirType::list_mail_in_maildir_fs(maildir_path, true)
20386+ .context("Could not parse files in maildir path")?;
20387+ let mut ctr = 0;
20388+ for file in files {
20389+ let hash = get_file_hash(&file);
20390+ let mut reader = std::io::BufReader::new(
20391+ std::fs::File::open(&file)
20392+ .with_context(|| format!("Could not open {}.", file.display()))?,
20393+ );
20394+ buf.clear();
20395+ reader
20396+ .read_to_end(&mut buf)
20397+ .with_context(|| format!("Could not read from {}.", file.display()))?;
20398+ match Envelope::from_bytes(buf.as_slice(), Some(file.flags())) {
20399+ Ok(mut env) => {
20400+ env.set_hash(hash);
20401+ if verbose > 1 {
20402+ println!(
20403+ "Inserting post from {:?} with subject `{}` and Message-ID `{}`.",
20404+ env.from(),
20405+ env.subject(),
20406+ env.message_id()
20407+ );
20408+ }
20409+ db.insert_post(list.pk, &buf, &env).with_context(|| {
20410+ format!(
20411+ "Could not insert post `{}` from path `{}`",
20412+ env.message_id(),
20413+ file.display()
20414+ )
20415+ })?;
20416+ ctr += 1;
20417+ }
20418+ Err(err) => {
20419+ if verbose > 0 || debug {
20420+ log::error!(
20421+ "Could not parse Envelope from file {}: {err}",
20422+ file.display()
20423+ );
20424+ }
20425+ }
20426+ }
20427+ }
20428+ if !quiet {
20429+ println!("Inserted {} posts to {}.", ctr, list_id);
20430+ }
20431+ Ok(())
20432+ }
20433+
20434+ pub fn update_postfix_config(
20435+ config_path: &Path,
20436+ db: &mut Connection,
20437+ master_cf: Option<PathBuf>,
20438+ PostfixConfig {
20439+ user,
20440+ group,
20441+ binary_path,
20442+ process_limit,
20443+ map_output_path,
20444+ transport_name,
20445+ }: PostfixConfig,
20446+ ) -> Result<()> {
20447+ let pfconf = mailpot::postfix::PostfixConfiguration {
20448+ user: user.into(),
20449+ group: group.map(Into::into),
20450+ binary_path,
20451+ process_limit,
20452+ map_output_path,
20453+ transport_name: transport_name.map(std::borrow::Cow::from),
20454+ };
20455+ pfconf
20456+ .save_maps(db.conf())
20457+ .context("Could not save maps.")?;
20458+ pfconf
20459+ .save_master_cf_entry(db.conf(), config_path, master_cf.as_deref())
20460+ .context("Could not save master.cf file.")?;
20461+
20462+ Ok(())
20463+ }
20464+
20465+ pub fn print_postfix_config(
20466+ config_path: &Path,
20467+ db: &mut Connection,
20468+ PostfixConfig {
20469+ user,
20470+ group,
20471+ binary_path,
20472+ process_limit,
20473+ map_output_path,
20474+ transport_name,
20475+ }: PostfixConfig,
20476+ ) -> Result<()> {
20477+ let pfconf = mailpot::postfix::PostfixConfiguration {
20478+ user: user.into(),
20479+ group: group.map(Into::into),
20480+ binary_path,
20481+ process_limit,
20482+ map_output_path,
20483+ transport_name: transport_name.map(std::borrow::Cow::from),
20484+ };
20485+ let lists = db.lists().context("Could not retrieve lists.")?;
20486+ let lists_post_policies = lists
20487+ .into_iter()
20488+ .map(|l| {
20489+ let pk = l.pk;
20490+ Ok((
20491+ l,
20492+ db.list_post_policy(pk).with_context(|| {
20493+ format!("Could not retrieve list post policy for list_pk = {pk}.")
20494+ })?,
20495+ ))
20496+ })
20497+ .collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
20498+ let maps = pfconf.generate_maps(&lists_post_policies);
20499+ let mastercf = pfconf.generate_master_cf_entry(db.conf(), config_path);
20500+
20501+ println!("{maps}\n\n{mastercf}\n");
20502+ Ok(())
20503+ }
20504+
20505+ pub fn accounts(db: &mut Connection, quiet: bool) -> Result<()> {
20506+ let accounts = db.accounts()?;
20507+ if accounts.is_empty() {
20508+ if !quiet {
20509+ println!("No accounts found.");
20510+ }
20511+ } else {
20512+ for a in accounts {
20513+ println!("- {:?}", a);
20514+ }
20515+ }
20516+ Ok(())
20517+ }
20518+
20519+ pub fn account_info(db: &mut Connection, address: &str, quiet: bool) -> Result<()> {
20520+ if let Some(acc) = db.account_by_address(address)? {
20521+ let subs = db
20522+ .account_subscriptions(acc.pk())
20523+ .context("Could not retrieve account subscriptions for this account.")?;
20524+ if subs.is_empty() {
20525+ if !quiet {
20526+ println!("No subscriptions found.");
20527+ }
20528+ } else {
20529+ for s in subs {
20530+ let list = db
20531+ .list(s.list)
20532+ .with_context(|| {
20533+ format!(
20534+ "Found subscription with list_pk = {} but could not retrieve the \
20535+ list.\nListSubscription = {:?}",
20536+ s.list, s
20537+ )
20538+ })?
20539+ .ok_or_else(|| {
20540+ format!(
20541+ "Found subscription with list_pk = {} but no such list \
20542+ exists.\nListSubscription = {:?}",
20543+ s.list, s
20544+ )
20545+ })?;
20546+ println!("- {:?} {}", s, list);
20547+ }
20548+ }
20549+ } else {
20550+ return Err(format!("Account with address {address} not found!").into());
20551+ }
20552+ Ok(())
20553+ }
20554+
20555+ pub fn add_account(
20556+ db: &mut Connection,
20557+ address: String,
20558+ password: String,
20559+ name: Option<String>,
20560+ public_key: Option<String>,
20561+ enabled: Option<bool>,
20562+ ) -> Result<()> {
20563+ db.add_account(Account {
20564+ pk: 0,
20565+ name,
20566+ address,
20567+ public_key,
20568+ password,
20569+ enabled: enabled.unwrap_or(true),
20570+ })?;
20571+ Ok(())
20572+ }
20573+
20574+ pub fn remove_account(db: &mut Connection, address: &str, quiet: bool) -> Result<()> {
20575+ let mut input = String::new();
20576+ if !quiet {
20577+ loop {
20578+ println!(
20579+ "Are you sure you want to remove account with address {}? [Yy/n]",
20580+ address
20581+ );
20582+ input.clear();
20583+ std::io::stdin().read_line(&mut input)?;
20584+ if input.trim() == "Y" || input.trim() == "y" || input.trim() == "" {
20585+ break;
20586+ } else if input.trim() == "n" {
20587+ return Ok(());
20588+ }
20589+ }
20590+ }
20591+
20592+ db.remove_account(address)?;
20593+
20594+ Ok(())
20595+ }
20596+
20597+ pub fn update_account(
20598+ db: &mut Connection,
20599+ address: String,
20600+ password: Option<String>,
20601+ name: Option<Option<String>>,
20602+ public_key: Option<Option<String>>,
20603+ enabled: Option<Option<bool>>,
20604+ ) -> Result<()> {
20605+ let changeset = AccountChangeset {
20606+ address,
20607+ name,
20608+ public_key,
20609+ password,
20610+ enabled,
20611+ };
20612+ db.update_account(changeset)?;
20613+ Ok(())
20614+ }
20615+
20616+ pub fn repair(
20617+ db: &mut Connection,
20618+ fix: bool,
20619+ all: bool,
20620+ mut datetime_header_value: bool,
20621+ mut remove_empty_accounts: bool,
20622+ mut remove_accepted_subscription_requests: bool,
20623+ mut warn_list_no_owner: bool,
20624+ ) -> Result<()> {
20625+ type LintFn = fn(&'_ mut mailpot::Connection, bool) -> std::result::Result<(), mailpot::Error>;
20626+ let dry_run = !fix;
20627+ if all {
20628+ datetime_header_value = true;
20629+ remove_empty_accounts = true;
20630+ remove_accepted_subscription_requests = true;
20631+ warn_list_no_owner = true;
20632+ }
20633+
20634+ if !(datetime_header_value
20635+ | remove_empty_accounts
20636+ | remove_accepted_subscription_requests
20637+ | warn_list_no_owner)
20638+ {
20639+ return Err("No lints selected: specify them with flag arguments. See --help".into());
20640+ }
20641+
20642+ if dry_run {
20643+ println!("running without making modifications (dry run)");
20644+ }
20645+
20646+ for (name, flag, lint_fn) in [
20647+ (
20648+ stringify!(datetime_header_value),
20649+ datetime_header_value,
20650+ datetime_header_value_lint as LintFn,
20651+ ),
20652+ (
20653+ stringify!(remove_empty_accounts),
20654+ remove_empty_accounts,
20655+ remove_empty_accounts_lint as _,
20656+ ),
20657+ (
20658+ stringify!(remove_accepted_subscription_requests),
20659+ remove_accepted_subscription_requests,
20660+ remove_accepted_subscription_requests_lint as _,
20661+ ),
20662+ (
20663+ stringify!(warn_list_no_owner),
20664+ warn_list_no_owner,
20665+ warn_list_no_owner_lint as _,
20666+ ),
20667+ ] {
20668+ if flag {
20669+ lint_fn(db, dry_run).with_context(|| format!("Lint {name} failed."))?;
20670+ }
20671+ }
20672+ Ok(())
20673+ }
20674 diff --git a/mailpot-cli/src/import.rs b/mailpot-cli/src/import.rs
20675new file mode 100644
20676index 0000000..f7425dd
20677--- /dev/null
20678+++ b/mailpot-cli/src/import.rs
20679 @@ -0,0 +1,149 @@
20680+ /*
20681+ * This file is part of mailpot
20682+ *
20683+ * Copyright 2023 - Manos Pitsidianakis
20684+ *
20685+ * This program is free software: you can redistribute it and/or modify
20686+ * it under the terms of the GNU Affero General Public License as
20687+ * published by the Free Software Foundation, either version 3 of the
20688+ * License, or (at your option) any later version.
20689+ *
20690+ * This program is distributed in the hope that it will be useful,
20691+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
20692+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20693+ * GNU Affero General Public License for more details.
20694+ *
20695+ * You should have received a copy of the GNU Affero General Public License
20696+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
20697+ */
20698+
20699+ use std::{borrow::Cow, time::Duration};
20700+
20701+ use base64::{engine::general_purpose, Engine as _};
20702+ use mailpot::models::{ListOwner, ListSubscription};
20703+ use ureq::Agent;
20704+
20705+ pub struct Mailman3Connection {
20706+ agent: Agent,
20707+ url: Cow<'static, str>,
20708+ auth: String,
20709+ }
20710+
20711+ impl Mailman3Connection {
20712+ pub fn new(
20713+ url: &str,
20714+ username: &str,
20715+ password: &str,
20716+ ) -> Result<Self, Box<dyn std::error::Error>> {
20717+ let agent: Agent = ureq::AgentBuilder::new()
20718+ .timeout_read(Duration::from_secs(5))
20719+ .timeout_write(Duration::from_secs(5))
20720+ .build();
20721+ let mut buf = String::new();
20722+ general_purpose::STANDARD
20723+ .encode_string(format!("{username}:{password}").as_bytes(), &mut buf);
20724+
20725+ let auth: String = format!("Basic {buf}");
20726+
20727+ Ok(Self {
20728+ agent,
20729+ url: url.trim_end_matches('/').to_string().into(),
20730+ auth,
20731+ })
20732+ }
20733+
20734+ pub fn users(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
20735+ let response: String = self
20736+ .agent
20737+ .get(&format!(
20738+ "{}/lists/{list_address}/roster/member?fields=email&fields=display_name",
20739+ self.url
20740+ ))
20741+ .set("Authorization", &self.auth)
20742+ .call()?
20743+ .into_string()?;
20744+ Ok(serde_json::from_str::<Roster>(&response)?.entries)
20745+ }
20746+
20747+ pub fn owners(&self, list_address: &str) -> Result<Vec<Entry>, Box<dyn std::error::Error>> {
20748+ let response: String = self
20749+ .agent
20750+ .get(&format!(
20751+ "{}/lists/{list_address}/roster/owner?fields=email&fields=display_name",
20752+ self.url
20753+ ))
20754+ .set("Authorization", &self.auth)
20755+ .call()?
20756+ .into_string()?;
20757+ Ok(serde_json::from_str::<Roster>(&response)?.entries)
20758+ }
20759+ }
20760+
20761+ #[derive(serde::Deserialize, Debug)]
20762+ pub struct Roster {
20763+ pub entries: Vec<Entry>,
20764+ }
20765+
20766+ #[derive(serde::Deserialize, Debug)]
20767+ pub struct Entry {
20768+ display_name: String,
20769+ email: String,
20770+ }
20771+
20772+ impl Entry {
20773+ pub fn display_name(&self) -> Option<&str> {
20774+ if !self.display_name.trim().is_empty() && &self.display_name != "None" {
20775+ Some(&self.display_name)
20776+ } else {
20777+ None
20778+ }
20779+ }
20780+
20781+ pub fn email(&self) -> &str {
20782+ &self.email
20783+ }
20784+
20785+ pub fn into_subscription(self, list: i64) -> ListSubscription {
20786+ let Self {
20787+ display_name,
20788+ email,
20789+ } = self;
20790+
20791+ ListSubscription {
20792+ pk: -1,
20793+ list,
20794+ address: email,
20795+ name: if !display_name.trim().is_empty() && &display_name != "None" {
20796+ Some(display_name)
20797+ } else {
20798+ None
20799+ },
20800+ account: None,
20801+ enabled: true,
20802+ verified: true,
20803+ digest: false,
20804+ hide_address: false,
20805+ receive_duplicates: false,
20806+ receive_own_posts: false,
20807+ receive_confirmation: false,
20808+ }
20809+ }
20810+
20811+ pub fn into_owner(self, list: i64) -> ListOwner {
20812+ let Self {
20813+ display_name,
20814+ email,
20815+ } = self;
20816+
20817+ ListOwner {
20818+ pk: -1,
20819+ list,
20820+ address: email,
20821+ name: if !display_name.trim().is_empty() && &display_name != "None" {
20822+ Some(display_name)
20823+ } else {
20824+ None
20825+ },
20826+ }
20827+ }
20828+ }
20829 diff --git a/mailpot-cli/src/lib.rs b/mailpot-cli/src/lib.rs
20830new file mode 100644
20831index 0000000..597fcbd
20832--- /dev/null
20833+++ b/mailpot-cli/src/lib.rs
20834 @@ -0,0 +1,29 @@
20835+ /*
20836+ * This file is part of mailpot
20837+ *
20838+ * Copyright 2020 - Manos Pitsidianakis
20839+ *
20840+ * This program is free software: you can redistribute it and/or modify
20841+ * it under the terms of the GNU Affero General Public License as
20842+ * published by the Free Software Foundation, either version 3 of the
20843+ * License, or (at your option) any later version.
20844+ *
20845+ * This program is distributed in the hope that it will be useful,
20846+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
20847+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20848+ * GNU Affero General Public License for more details.
20849+ *
20850+ * You should have received a copy of the GNU Affero General Public License
20851+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
20852+ */
20853+
20854+ extern crate base64;
20855+ extern crate ureq;
20856+ pub use std::path::PathBuf;
20857+
20858+ mod args;
20859+ pub mod commands;
20860+ pub mod import;
20861+ pub mod lints;
20862+ pub use args::*;
20863+ pub use clap::{Args, CommandFactory, Parser, Subcommand};
20864 diff --git a/mailpot-cli/src/lints.rs b/mailpot-cli/src/lints.rs
20865new file mode 100644
20866index 0000000..5d7fa01
20867--- /dev/null
20868+++ b/mailpot-cli/src/lints.rs
20869 @@ -0,0 +1,262 @@
20870+ /*
20871+ * This file is part of mailpot
20872+ *
20873+ * Copyright 2020 - Manos Pitsidianakis
20874+ *
20875+ * This program is free software: you can redistribute it and/or modify
20876+ * it under the terms of the GNU Affero General Public License as
20877+ * published by the Free Software Foundation, either version 3 of the
20878+ * License, or (at your option) any later version.
20879+ *
20880+ * This program is distributed in the hope that it will be useful,
20881+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
20882+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20883+ * GNU Affero General Public License for more details.
20884+ *
20885+ * You should have received a copy of the GNU Affero General Public License
20886+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
20887+ */
20888+
20889+ use mailpot::{
20890+ chrono,
20891+ melib::{self, Envelope},
20892+ models::{Account, DbVal, ListSubscription, MailingList},
20893+ rusqlite, Connection, Result,
20894+ };
20895+
20896+ pub fn datetime_header_value_lint(db: &mut Connection, dry_run: bool) -> Result<()> {
20897+ let mut col = vec![];
20898+ {
20899+ let mut stmt = db.connection.prepare("SELECT * FROM post ORDER BY pk")?;
20900+ let iter = stmt.query_map([], |row| {
20901+ let pk: i64 = row.get("pk")?;
20902+ let date_s: String = row.get("datetime")?;
20903+ match melib::utils::datetime::rfc822_to_timestamp(date_s.trim()) {
20904+ Err(_) | Ok(0) => {
20905+ let mut timestamp: i64 = row.get("timestamp")?;
20906+ let created: i64 = row.get("created")?;
20907+ if timestamp == 0 {
20908+ timestamp = created;
20909+ }
20910+ timestamp = std::cmp::min(timestamp, created);
20911+ let timestamp = if timestamp <= 0 {
20912+ None
20913+ } else {
20914+ // safe because we checked it's not negative or zero above.
20915+ Some(timestamp as u64)
20916+ };
20917+ let message: Vec<u8> = row.get("message")?;
20918+ Ok(Some((pk, date_s, message, timestamp)))
20919+ }
20920+ Ok(_) => Ok(None),
20921+ }
20922+ })?;
20923+
20924+ for entry in iter {
20925+ if let Some(s) = entry? {
20926+ col.push(s);
20927+ }
20928+ }
20929+ }
20930+ let mut failures = 0;
20931+ let tx = if dry_run {
20932+ None
20933+ } else {
20934+ Some(db.connection.transaction()?)
20935+ };
20936+ if col.is_empty() {
20937+ println!("datetime_header_value: ok");
20938+ } else {
20939+ println!("datetime_header_value: found {} entries", col.len());
20940+ println!("pk\tDate value\tshould be");
20941+ for (pk, val, message, timestamp) in col {
20942+ let correct = if let Ok(v) =
20943+ chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc3339(&val)
20944+ {
20945+ v.to_rfc2822()
20946+ } else if let Some(v) = timestamp.map(|t| {
20947+ melib::utils::datetime::timestamp_to_string(
20948+ t,
20949+ Some(melib::utils::datetime::formats::RFC822_DATE),
20950+ true,
20951+ )
20952+ }) {
20953+ v
20954+ } else if let Ok(v) =
20955+ Envelope::from_bytes(&message, None).map(|env| env.date_as_str().to_string())
20956+ {
20957+ v
20958+ } else {
20959+ failures += 1;
20960+ println!("{pk}\t{val}\tCould not find any valid date value in the post metadata!");
20961+ continue;
20962+ };
20963+ println!("{pk}\t{val}\t{correct}");
20964+ if let Some(tx) = tx.as_ref() {
20965+ tx.execute(
20966+ "UPDATE post SET datetime = ? WHERE pk = ?",
20967+ rusqlite::params![&correct, pk],
20968+ )?;
20969+ }
20970+ }
20971+ }
20972+ if let Some(tx) = tx {
20973+ tx.commit()?;
20974+ }
20975+ if failures > 0 {
20976+ println!(
20977+ "datetime_header_value: {failures} failure{}",
20978+ if failures == 1 { "" } else { "s" }
20979+ );
20980+ }
20981+ Ok(())
20982+ }
20983+
20984+ pub fn remove_empty_accounts_lint(db: &mut Connection, dry_run: bool) -> Result<()> {
20985+ let mut col = vec![];
20986+ {
20987+ let mut stmt = db.connection.prepare(
20988+ "SELECT * FROM account WHERE NOT EXISTS (SELECT 1 FROM subscription AS s WHERE \
20989+ s.address = address) ORDER BY pk",
20990+ )?;
20991+ let iter = stmt.query_map([], |row| {
20992+ let pk = row.get("pk")?;
20993+ Ok(DbVal(
20994+ Account {
20995+ pk,
20996+ name: row.get("name")?,
20997+ address: row.get("address")?,
20998+ public_key: row.get("public_key")?,
20999+ password: row.get("password")?,
21000+ enabled: row.get("enabled")?,
21001+ },
21002+ pk,
21003+ ))
21004+ })?;
21005+
21006+ for entry in iter {
21007+ let entry = entry?;
21008+ col.push(entry);
21009+ }
21010+ }
21011+ if col.is_empty() {
21012+ println!("remove_empty_accounts: ok");
21013+ } else {
21014+ let tx = if dry_run {
21015+ None
21016+ } else {
21017+ Some(db.connection.transaction()?)
21018+ };
21019+ println!("remove_empty_accounts: found {} entries", col.len());
21020+ println!("pk\tAddress");
21021+ for DbVal(Account { pk, address, .. }, _) in &col {
21022+ println!("{pk}\t{address}");
21023+ }
21024+ if let Some(tx) = tx {
21025+ for DbVal(_, pk) in col {
21026+ tx.execute("DELETE FROM account WHERE pk = ?", [pk])?;
21027+ }
21028+ tx.commit()?;
21029+ }
21030+ }
21031+ Ok(())
21032+ }
21033+
21034+ pub fn remove_accepted_subscription_requests_lint(
21035+ db: &mut Connection,
21036+ dry_run: bool,
21037+ ) -> Result<()> {
21038+ let mut col = vec![];
21039+ {
21040+ let mut stmt = db.connection.prepare(
21041+ "SELECT * FROM candidate_subscription WHERE accepted IS NOT NULL ORDER BY pk",
21042+ )?;
21043+ let iter = stmt.query_map([], |row| {
21044+ let pk = row.get("pk")?;
21045+ Ok(DbVal(
21046+ ListSubscription {
21047+ pk,
21048+ list: row.get("list")?,
21049+ address: row.get("address")?,
21050+ account: row.get("account")?,
21051+ name: row.get("name")?,
21052+ digest: row.get("digest")?,
21053+ enabled: row.get("enabled")?,
21054+ verified: row.get("verified")?,
21055+ hide_address: row.get("hide_address")?,
21056+ receive_duplicates: row.get("receive_duplicates")?,
21057+ receive_own_posts: row.get("receive_own_posts")?,
21058+ receive_confirmation: row.get("receive_confirmation")?,
21059+ },
21060+ pk,
21061+ ))
21062+ })?;
21063+
21064+ for entry in iter {
21065+ let entry = entry?;
21066+ col.push(entry);
21067+ }
21068+ }
21069+ if col.is_empty() {
21070+ println!("remove_accepted_subscription_requests: ok");
21071+ } else {
21072+ let tx = if dry_run {
21073+ None
21074+ } else {
21075+ Some(db.connection.transaction()?)
21076+ };
21077+ println!(
21078+ "remove_accepted_subscription_requests: found {} entries",
21079+ col.len()
21080+ );
21081+ println!("pk\tAddress");
21082+ for DbVal(ListSubscription { pk, address, .. }, _) in &col {
21083+ println!("{pk}\t{address}");
21084+ }
21085+ if let Some(tx) = tx {
21086+ for DbVal(_, pk) in col {
21087+ tx.execute("DELETE FROM candidate_subscription WHERE pk = ?", [pk])?;
21088+ }
21089+ tx.commit()?;
21090+ }
21091+ }
21092+ Ok(())
21093+ }
21094+
21095+ pub fn warn_list_no_owner_lint(db: &mut Connection, _: bool) -> Result<()> {
21096+ let mut stmt = db.connection.prepare(
21097+ "SELECT * FROM list WHERE NOT EXISTS (SELECT 1 FROM owner AS o WHERE o.list = pk) ORDER \
21098+ BY pk",
21099+ )?;
21100+ let iter = stmt.query_map([], |row| {
21101+ let pk = row.get("pk")?;
21102+ Ok(DbVal(
21103+ MailingList {
21104+ pk,
21105+ name: row.get("name")?,
21106+ id: row.get("id")?,
21107+ address: row.get("address")?,
21108+ description: row.get("description")?,
21109+ topics: vec![],
21110+ archive_url: row.get("archive_url")?,
21111+ },
21112+ pk,
21113+ ))
21114+ })?;
21115+
21116+ let mut col = vec![];
21117+ for entry in iter {
21118+ let entry = entry?;
21119+ col.push(entry);
21120+ }
21121+ if col.is_empty() {
21122+ println!("warn_list_no_owner: ok");
21123+ } else {
21124+ println!("warn_list_no_owner: found {} entries", col.len());
21125+ println!("pk\tName");
21126+ for DbVal(MailingList { pk, name, .. }, _) in col {
21127+ println!("{pk}\t{name}");
21128+ }
21129+ }
21130+ Ok(())
21131+ }
21132 diff --git a/mailpot-cli/src/main.rs b/mailpot-cli/src/main.rs
21133new file mode 100644
21134index 0000000..3b23746
21135--- /dev/null
21136+++ b/mailpot-cli/src/main.rs
21137 @@ -0,0 +1,221 @@
21138+ /*
21139+ * This file is part of mailpot
21140+ *
21141+ * Copyright 2020 - Manos Pitsidianakis
21142+ *
21143+ * This program is free software: you can redistribute it and/or modify
21144+ * it under the terms of the GNU Affero General Public License as
21145+ * published by the Free Software Foundation, either version 3 of the
21146+ * License, or (at your option) any later version.
21147+ *
21148+ * This program is distributed in the hope that it will be useful,
21149+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
21150+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21151+ * GNU Affero General Public License for more details.
21152+ *
21153+ * You should have received a copy of the GNU Affero General Public License
21154+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
21155+ */
21156+
21157+ use mailpot::{melib::smtp, Configuration, Connection, Context, Result};
21158+ use mailpot_cli::{commands::*, *};
21159+
21160+ fn run_app(
21161+ config: Option<PathBuf>,
21162+ cmd: Command,
21163+ debug: bool,
21164+ quiet: bool,
21165+ verbose: u8,
21166+ ) -> Result<()> {
21167+ if let Command::SampleConfig { with_smtp } = cmd {
21168+ let mut new = Configuration::new("/path/to/sqlite.db");
21169+ new.administrators.push("admin@example.com".to_string());
21170+ if with_smtp {
21171+ new.send_mail = mailpot::SendMail::Smtp(smtp::SmtpServerConf {
21172+ hostname: "mail.example.com".to_string(),
21173+ port: 587,
21174+ envelope_from: "".to_string(),
21175+ auth: smtp::SmtpAuth::Auto {
21176+ username: "user".to_string(),
21177+ password: smtp::Password::Raw("hunter2".to_string()),
21178+ auth_type: smtp::SmtpAuthType::default(),
21179+ require_auth: true,
21180+ },
21181+ security: smtp::SmtpSecurity::StartTLS {
21182+ danger_accept_invalid_certs: false,
21183+ },
21184+ extensions: Default::default(),
21185+ });
21186+ }
21187+ println!("{}", new.to_toml());
21188+ return Ok(());
21189+ };
21190+ let config_path = if let Some(path) = config.as_deref() {
21191+ path
21192+ } else {
21193+ let mut opt = Opt::command();
21194+ opt.error(
21195+ clap::error::ErrorKind::MissingRequiredArgument,
21196+ "--config is required for mailing list operations",
21197+ )
21198+ .exit();
21199+ };
21200+
21201+ let config = Configuration::from_file(config_path).with_context(|| {
21202+ format!(
21203+ "Could not read configuration file from path: {}",
21204+ config_path.display()
21205+ )
21206+ })?;
21207+
21208+ use Command::*;
21209+ let mut db = Connection::open_or_create_db(config)
21210+ .context("Could not open database connection with this configuration")?
21211+ .trusted();
21212+ match cmd {
21213+ SampleConfig { .. } => {}
21214+ DumpDatabase => {
21215+ dump_database(&mut db).context("Could not dump database.")?;
21216+ }
21217+ ListLists => {
21218+ list_lists(&mut db).context("Could not retrieve mailing lists.")?;
21219+ }
21220+ List { list_id, cmd } => {
21221+ list(&mut db, &list_id, cmd, quiet).map_err(|err| {
21222+ err.chain_err(|| {
21223+ mailpot::Error::from(format!("Could not perform list command for {list_id}."))
21224+ })
21225+ })?;
21226+ }
21227+ CreateList {
21228+ name,
21229+ id,
21230+ address,
21231+ description,
21232+ archive_url,
21233+ } => {
21234+ create_list(&mut db, name, id, address, description, archive_url, quiet)
21235+ .context("Could not create list.")?;
21236+ }
21237+ Post { dry_run } => {
21238+ post(&mut db, dry_run, debug).context("Could not process post.")?;
21239+ }
21240+ FlushQueue { dry_run } => {
21241+ flush_queue(&mut db, dry_run, verbose, debug).with_context(|| {
21242+ format!("Could not flush queue {}.", mailpot::queue::Queue::Out)
21243+ })?;
21244+ }
21245+ Queue { queue, cmd } => {
21246+ queue_(&mut db, queue, cmd, quiet)
21247+ .with_context(|| format!("Could not perform queue command for queue `{queue}`."))?;
21248+ }
21249+ ImportMaildir {
21250+ list_id,
21251+ maildir_path,
21252+ } => {
21253+ import_maildir(
21254+ &mut db,
21255+ &list_id,
21256+ maildir_path.clone(),
21257+ quiet,
21258+ debug,
21259+ verbose,
21260+ )
21261+ .with_context(|| {
21262+ format!(
21263+ "Could not import maildir path {} to list `{list_id}`.",
21264+ maildir_path.display(),
21265+ )
21266+ })?;
21267+ }
21268+ UpdatePostfixConfig { master_cf, config } => {
21269+ update_postfix_config(config_path, &mut db, master_cf, config)
21270+ .context("Could not update postfix configuration.")?;
21271+ }
21272+ PrintPostfixConfig { config } => {
21273+ print_postfix_config(config_path, &mut db, config)
21274+ .context("Could not print postfix configuration.")?;
21275+ }
21276+ Accounts => {
21277+ accounts(&mut db, quiet).context("Could not retrieve accounts.")?;
21278+ }
21279+ AccountInfo { address } => {
21280+ account_info(&mut db, &address, quiet).with_context(|| {
21281+ format!("Could not retrieve account info for address {address}.")
21282+ })?;
21283+ }
21284+ AddAccount {
21285+ address,
21286+ password,
21287+ name,
21288+ public_key,
21289+ enabled,
21290+ } => {
21291+ add_account(&mut db, address, password, name, public_key, enabled)
21292+ .context("Could not add account.")?;
21293+ }
21294+ RemoveAccount { address } => {
21295+ remove_account(&mut db, &address, quiet)
21296+ .with_context(|| format!("Could not remove account with address {address}."))?;
21297+ }
21298+ UpdateAccount {
21299+ address,
21300+ password,
21301+ name,
21302+ public_key,
21303+ enabled,
21304+ } => {
21305+ update_account(&mut db, address, password, name, public_key, enabled)
21306+ .context("Could not update account.")?;
21307+ }
21308+ Repair {
21309+ fix,
21310+ all,
21311+ datetime_header_value,
21312+ remove_empty_accounts,
21313+ remove_accepted_subscription_requests,
21314+ warn_list_no_owner,
21315+ } => {
21316+ repair(
21317+ &mut db,
21318+ fix,
21319+ all,
21320+ datetime_header_value,
21321+ remove_empty_accounts,
21322+ remove_accepted_subscription_requests,
21323+ warn_list_no_owner,
21324+ )
21325+ .context("Could not perform database repair.")?;
21326+ }
21327+ }
21328+
21329+ Ok(())
21330+ }
21331+
21332+ fn main() -> std::result::Result<(), i32> {
21333+ let opt = Opt::parse();
21334+ stderrlog::new()
21335+ .module(module_path!())
21336+ .module("mailpot")
21337+ .quiet(opt.quiet)
21338+ .verbosity(opt.verbose as usize)
21339+ .timestamp(opt.ts.unwrap_or(stderrlog::Timestamp::Off))
21340+ .init()
21341+ .unwrap();
21342+ if opt.debug {
21343+ println!("DEBUG: {:?}", &opt);
21344+ }
21345+ let Opt {
21346+ config,
21347+ cmd,
21348+ debug,
21349+ quiet,
21350+ verbose,
21351+ ..
21352+ } = opt;
21353+ if let Err(err) = run_app(config, cmd, debug, quiet, verbose) {
21354+ print!("{}", err.display_chain());
21355+ std::process::exit(-1);
21356+ }
21357+ Ok(())
21358+ }
21359 diff --git a/mailpot-cli/tests/basic_interfaces.rs b/mailpot-cli/tests/basic_interfaces.rs
21360new file mode 100644
21361index 0000000..8e8a438
21362--- /dev/null
21363+++ b/mailpot-cli/tests/basic_interfaces.rs
21364 @@ -0,0 +1,268 @@
21365+ /*
21366+ * meli - email module
21367+ *
21368+ * Copyright 2019 Manos Pitsidianakis
21369+ *
21370+ * This file is part of meli.
21371+ *
21372+ * meli is free software: you can redistribute it and/or modify
21373+ * it under the terms of the GNU General Public License as published by
21374+ * the Free Software Foundation, either version 3 of the License, or
21375+ * (at your option) any later version.
21376+ *
21377+ * meli is distributed in the hope that it will be useful,
21378+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
21379+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21380+ * GNU General Public License for more details.
21381+ *
21382+ * You should have received a copy of the GNU General Public License
21383+ * along with meli. If not, see <http://www.gnu.org/licenses/>.
21384+ */
21385+
21386+ #![deny(dead_code)]
21387+
21388+ use std::path::Path;
21389+
21390+ use assert_cmd::{assert::OutputAssertExt, Command};
21391+ use mailpot::{models::*, Configuration, Connection, SendMail};
21392+ use predicates::prelude::*;
21393+ use tempfile::TempDir;
21394+
21395+ #[test]
21396+ fn test_cli_basic_interfaces() {
21397+ fn no_args() {
21398+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21399+ // 2 -> incorrect usage
21400+ cmd.assert().code(2);
21401+ }
21402+
21403+ fn version() {
21404+ // --version is successful
21405+ for arg in ["--version", "-V"] {
21406+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21407+ let output = cmd.arg(arg).output().unwrap().assert();
21408+ output.code(0).stdout(predicates::str::starts_with("mpot "));
21409+ }
21410+ }
21411+
21412+ fn help() {
21413+ // --help is successful
21414+ for (arg, starts_with) in [
21415+ ("--help", "GNU Affero version 3 or later"),
21416+ ("-h", "mailing list manager"),
21417+ ] {
21418+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21419+ let output = cmd.arg(arg).output().unwrap().assert();
21420+ output
21421+ .code(0)
21422+ .stdout(predicates::str::starts_with(starts_with))
21423+ .stdout(predicates::str::contains("Usage:"));
21424+ }
21425+ }
21426+
21427+ fn sample_config() {
21428+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21429+ // sample-config does not require a configuration file as an argument (but other
21430+ // commands do)
21431+ let output = cmd.arg("sample-config").output().unwrap().assert();
21432+ output.code(0).stdout(predicates::str::is_empty().not());
21433+ }
21434+
21435+ fn config_required() {
21436+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21437+ let output = cmd.arg("list-lists").output().unwrap().assert();
21438+ output.code(2).stdout(predicates::str::is_empty()).stderr(
21439+ predicate::eq(
21440+ r#"error: --config is required for mailing list operations
21441+
21442+ Usage: mpot [OPTIONS] <COMMAND>
21443+
21444+ For more information, try '--help'."#,
21445+ )
21446+ .trim()
21447+ .normalize(),
21448+ );
21449+ }
21450+
21451+ no_args();
21452+ version();
21453+ help();
21454+ sample_config();
21455+ config_required();
21456+
21457+ let tmp_dir = TempDir::new().unwrap();
21458+
21459+ let conf_path = tmp_dir.path().join("conf.toml");
21460+ let db_path = tmp_dir.path().join("mpot.db");
21461+
21462+ let config = Configuration {
21463+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
21464+ db_path,
21465+ data_path: tmp_dir.path().to_path_buf(),
21466+ administrators: vec![],
21467+ };
21468+
21469+ let config_str = config.to_toml();
21470+
21471+ fn config_not_exists(conf: &Path) {
21472+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21473+ let output = cmd
21474+ .arg("-c")
21475+ .arg(conf)
21476+ .arg("list-lists")
21477+ .output()
21478+ .unwrap()
21479+ .assert();
21480+ output.code(255).stderr(predicates::str::is_empty()).stdout(
21481+ predicate::eq(
21482+ format!(
21483+ "[1] Could not read configuration file from path: {path} Caused by:\n[2] \
21484+ Configuration file {path} not found. Caused by:\n[3] Error returned from \
21485+ internal I/O operation: No such file or directory (os error 2)",
21486+ path = conf.display()
21487+ )
21488+ .as_str(),
21489+ )
21490+ .trim()
21491+ .normalize(),
21492+ );
21493+ }
21494+
21495+ config_not_exists(&conf_path);
21496+
21497+ std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
21498+
21499+ fn list_lists(conf: &Path, eq: &str) {
21500+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21501+ let output = cmd
21502+ .arg("-c")
21503+ .arg(conf)
21504+ .arg("list-lists")
21505+ .output()
21506+ .unwrap()
21507+ .assert();
21508+ output
21509+ .code(0)
21510+ .stderr(predicates::str::is_empty())
21511+ .stdout(predicate::eq(eq).trim().normalize());
21512+ }
21513+
21514+ list_lists(&conf_path, "No lists found.");
21515+
21516+ {
21517+ let db = Connection::open_or_create_db(config).unwrap().trusted();
21518+
21519+ let foo_chat = db
21520+ .create_list(MailingList {
21521+ pk: 0,
21522+ name: "foobar chat".into(),
21523+ id: "foo-chat".into(),
21524+ address: "foo-chat@example.com".into(),
21525+ topics: vec![],
21526+ description: None,
21527+ archive_url: None,
21528+ })
21529+ .unwrap();
21530+
21531+ assert_eq!(foo_chat.pk(), 1);
21532+ }
21533+ list_lists(
21534+ &conf_path,
21535+ "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
21536+ \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
21537+ owners: None\n\tPost policy: None\n\tSubscription policy: None",
21538+ );
21539+
21540+ fn create_list(conf: &Path) {
21541+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21542+ let output = cmd
21543+ .arg("-c")
21544+ .arg(conf)
21545+ .arg("create-list")
21546+ .arg("--name")
21547+ .arg("twobar")
21548+ .arg("--id")
21549+ .arg("twobar-chat")
21550+ .arg("--address")
21551+ .arg("twobar-chat@example.com")
21552+ .output()
21553+ .unwrap()
21554+ .assert();
21555+ output.code(0).stderr(predicates::str::is_empty()).stdout(
21556+ predicate::eq("Created new list \"twobar-chat\" with primary key 2")
21557+ .trim()
21558+ .normalize(),
21559+ );
21560+ }
21561+ create_list(&conf_path);
21562+ list_lists(
21563+ &conf_path,
21564+ "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
21565+ \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
21566+ owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
21567+ DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
21568+ \"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
21569+ 2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
21570+ );
21571+
21572+ fn add_list_owner(conf: &Path) {
21573+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21574+ let output = cmd
21575+ .arg("-c")
21576+ .arg(conf)
21577+ .arg("list")
21578+ .arg("twobar-chat")
21579+ .arg("add-list-owner")
21580+ .arg("--address")
21581+ .arg("list-owner@example.com")
21582+ .output()
21583+ .unwrap()
21584+ .assert();
21585+ output.code(0).stderr(predicates::str::is_empty()).stdout(
21586+ predicate::eq("Added new list owner [#1 2] list-owner@example.com")
21587+ .trim()
21588+ .normalize(),
21589+ );
21590+ }
21591+ add_list_owner(&conf_path);
21592+ list_lists(
21593+ &conf_path,
21594+ "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
21595+ \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
21596+ owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
21597+ DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
21598+ \"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
21599+ 2)\n\tList owners:\n\t- [#1 2] list-owner@example.com\n\tPost policy: \
21600+ None\n\tSubscription policy: None",
21601+ );
21602+
21603+ fn remove_list_owner(conf: &Path) {
21604+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21605+ let output = cmd
21606+ .arg("-c")
21607+ .arg(conf)
21608+ .arg("list")
21609+ .arg("twobar-chat")
21610+ .arg("remove-list-owner")
21611+ .arg("--pk")
21612+ .arg("1")
21613+ .output()
21614+ .unwrap()
21615+ .assert();
21616+ output.code(0).stderr(predicates::str::is_empty()).stdout(
21617+ predicate::eq("Removed list owner with pk = 1")
21618+ .trim()
21619+ .normalize(),
21620+ );
21621+ }
21622+ remove_list_owner(&conf_path);
21623+ list_lists(
21624+ &conf_path,
21625+ "- foo-chat DbVal(MailingList { pk: 1, name: \"foobar chat\", id: \"foo-chat\", address: \
21626+ \"foo-chat@example.com\", topics: [], description: None, archive_url: None }, 1)\n\tList \
21627+ owners: None\n\tPost policy: None\n\tSubscription policy: None\n\n- twobar-chat \
21628+ DbVal(MailingList { pk: 2, name: \"twobar\", id: \"twobar-chat\", address: \
21629+ \"twobar-chat@example.com\", topics: [], description: None, archive_url: None }, \
21630+ 2)\n\tList owners: None\n\tPost policy: None\n\tSubscription policy: None",
21631+ );
21632+ }
21633 diff --git a/mailpot-cli/tests/out_queue_flush.rs b/mailpot-cli/tests/out_queue_flush.rs
21634new file mode 100644
21635index 0000000..5eb62b4
21636--- /dev/null
21637+++ b/mailpot-cli/tests/out_queue_flush.rs
21638 @@ -0,0 +1,398 @@
21639+ /*
21640+ * meli - email module
21641+ *
21642+ * Copyright 2019 Manos Pitsidianakis
21643+ *
21644+ * This file is part of meli.
21645+ *
21646+ * meli is free software: you can redistribute it and/or modify
21647+ * it under the terms of the GNU General Public License as published by
21648+ * the Free Software Foundation, either version 3 of the License, or
21649+ * (at your option) any later version.
21650+ *
21651+ * meli is distributed in the hope that it will be useful,
21652+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
21653+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21654+ * GNU General Public License for more details.
21655+ *
21656+ * You should have received a copy of the GNU General Public License
21657+ * along with meli. If not, see <http://www.gnu.org/licenses/>.
21658+ */
21659+
21660+ use assert_cmd::assert::OutputAssertExt;
21661+ use mailpot::{
21662+ melib,
21663+ models::{changesets::ListSubscriptionChangeset, *},
21664+ queue::Queue,
21665+ Configuration, Connection, SendMail,
21666+ };
21667+ use mailpot_tests::*;
21668+ use predicates::prelude::*;
21669+ use tempfile::TempDir;
21670+
21671+ fn generate_mail(from: &str, to: &str, subject: &str, body: &str, seq: &mut usize) -> String {
21672+ format!(
21673+ "From: {from}@example.com
21674+ To: <foo-chat{to}@example.com>
21675+ Subject: {subject}
21676+ Date: Thu, 29 Oct 2020 13:58:16 +0000
21677+ Message-ID:
21678+ <aaa{}@example.com>
21679+ Content-Language: en-US
21680+ Content-Type: text/plain
21681+
21682+ {body}
21683+ ",
21684+ {
21685+ let val = *seq;
21686+ *seq += 1;
21687+ val
21688+ }
21689+ )
21690+ }
21691+
21692+ #[test]
21693+ fn test_out_queue_flush() {
21694+ use assert_cmd::Command;
21695+
21696+ let tmp_dir = TempDir::new().unwrap();
21697+
21698+ let conf_path = tmp_dir.path().join("conf.toml");
21699+ let db_path = tmp_dir.path().join("mpot.db");
21700+ let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8826").build();
21701+ let config = Configuration {
21702+ send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
21703+ db_path,
21704+ data_path: tmp_dir.path().to_path_buf(),
21705+ administrators: vec![],
21706+ };
21707+
21708+ let config_str = config.to_toml();
21709+
21710+ std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
21711+
21712+ log::info!("Creating foo-chat@example.com mailing list.");
21713+ let post_policy;
21714+ let foo_chat = {
21715+ let db = Connection::open_or_create_db(config.clone())
21716+ .unwrap()
21717+ .trusted();
21718+
21719+ let foo_chat = db
21720+ .create_list(MailingList {
21721+ pk: 0,
21722+ name: "foobar chat".into(),
21723+ id: "foo-chat".into(),
21724+ address: "foo-chat@example.com".into(),
21725+ description: None,
21726+ topics: vec![],
21727+ archive_url: None,
21728+ })
21729+ .unwrap();
21730+
21731+ assert_eq!(foo_chat.pk(), 1);
21732+ post_policy = db
21733+ .set_list_post_policy(PostPolicy {
21734+ pk: -1,
21735+ list: foo_chat.pk(),
21736+ announce_only: false,
21737+ subscription_only: false,
21738+ approval_needed: false,
21739+ open: true,
21740+ custom: false,
21741+ })
21742+ .unwrap();
21743+ foo_chat
21744+ };
21745+
21746+ let headers_fn = |env: &melib::Envelope| {
21747+ assert!(env.subject().starts_with(&format!("[{}] ", foo_chat.id)));
21748+ let headers = env.other_headers();
21749+
21750+ assert_eq!(
21751+ headers
21752+ .get(melib::HeaderName::LIST_ID)
21753+ .map(|header| header.to_string()),
21754+ Some(foo_chat.id_header())
21755+ );
21756+ assert_eq!(
21757+ headers
21758+ .get(melib::HeaderName::LIST_HELP)
21759+ .map(|header| header.to_string()),
21760+ foo_chat.help_header()
21761+ );
21762+ assert_eq!(
21763+ headers
21764+ .get(melib::HeaderName::LIST_POST)
21765+ .map(|header| header.to_string()),
21766+ foo_chat.post_header(Some(&post_policy))
21767+ );
21768+ };
21769+
21770+ log::info!("Running mpot flush-queue on empty out queue.");
21771+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21772+ let output = cmd
21773+ .arg("-vv")
21774+ .arg("-c")
21775+ .arg(&conf_path)
21776+ .arg("flush-queue")
21777+ .output()
21778+ .unwrap()
21779+ .assert();
21780+ output.code(0).stderr(predicates::str::is_empty()).stdout(
21781+ predicate::eq("Queue out has 0 messages.")
21782+ .trim()
21783+ .normalize(),
21784+ );
21785+
21786+ let mut seq = 0; // for generated emails
21787+ log::info!("Subscribe two users, Αλίκη and Χαραλάμπης to foo-chat.");
21788+
21789+ {
21790+ let db = Connection::open_or_create_db(config.clone())
21791+ .unwrap()
21792+ .trusted();
21793+
21794+ for who in ["Αλίκη", "Χαραλάμπης"] {
21795+ // = ["Alice", "Bob"]
21796+ let mail = generate_mail(who, "+request", "subscribe", "", &mut seq);
21797+ let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
21798+ .expect("Could not parse message");
21799+ db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
21800+ .unwrap();
21801+ }
21802+ db.update_subscription(ListSubscriptionChangeset {
21803+ list: foo_chat.pk(),
21804+ address: "Χαραλάμπης@example.com".into(),
21805+ receive_own_posts: Some(true),
21806+ ..Default::default()
21807+ })
21808+ .unwrap();
21809+ let out_queue = db.queue(Queue::Out).unwrap();
21810+ assert_eq!(out_queue.len(), 2);
21811+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 2);
21812+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
21813+ }
21814+
21815+ log::info!("Flush out queue, subscription confirmations should be sent to the new users.");
21816+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21817+ let output = cmd
21818+ .arg("-vv")
21819+ .arg("-c")
21820+ .arg(&conf_path)
21821+ .arg("flush-queue")
21822+ .output()
21823+ .unwrap()
21824+ .assert();
21825+ output.code(0).stdout(
21826+ predicate::eq("Queue out has 2 messages.")
21827+ .trim()
21828+ .normalize(),
21829+ );
21830+
21831+ /* Check that confirmation emails are correct */
21832+ let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
21833+ assert_eq!(stored.len(), 2);
21834+ assert_eq!(stored[0].0, "=?UTF-8?B?zpHOu86vzrrOtw==?=@example.com");
21835+ assert_eq!(
21836+ stored[1].0,
21837+ "=?UTF-8?B?zqfOsc+BzrHOu86szrzPgM63z4I=?=@example.com"
21838+ );
21839+ for item in stored.iter() {
21840+ assert_eq!(
21841+ item.1.subject(),
21842+ "[foo-chat] You have successfully subscribed to foobar chat."
21843+ );
21844+ assert_eq!(
21845+ &item.1.field_from_to_string(),
21846+ "foo-chat+request@example.com"
21847+ );
21848+ headers_fn(&item.1);
21849+ }
21850+
21851+ log::info!(
21852+ "Χαραλάμπης submits a post to list. Flush out queue, Χαραλάμπης' post should be relayed \
21853+ to Αλίκη, and Χαραλάμπης should receive a copy of their own post because of \
21854+ `receive_own_posts` setting."
21855+ );
21856+
21857+ {
21858+ let db = Connection::open_or_create_db(config.clone())
21859+ .unwrap()
21860+ .trusted();
21861+ let mail = generate_mail("Χαραλάμπης", "", "hello world", "Hello there.", &mut seq);
21862+ let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
21863+ .expect("Could not parse message");
21864+ db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
21865+ .unwrap();
21866+ let out_queue = db.queue(Queue::Out).unwrap();
21867+ assert_eq!(out_queue.len(), 2);
21868+ }
21869+
21870+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21871+ let output = cmd
21872+ .arg("-vv")
21873+ .arg("-c")
21874+ .arg(&conf_path)
21875+ .arg("flush-queue")
21876+ .output()
21877+ .unwrap()
21878+ .assert();
21879+ output.code(0).stdout(
21880+ predicate::eq("Queue out has 2 messages.")
21881+ .trim()
21882+ .normalize(),
21883+ );
21884+
21885+ /* Check that user posts are correct */
21886+ {
21887+ let db = Connection::open_or_create_db(config).unwrap().trusted();
21888+
21889+ let out_queue = db.queue(Queue::Out).unwrap();
21890+ assert_eq!(out_queue.len(), 0);
21891+ }
21892+
21893+ let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
21894+ assert_eq!(stored.len(), 2);
21895+ assert_eq!(stored[0].0, "Αλίκη@example.com");
21896+ assert_eq!(stored[1].0, "Χαραλάμπης@example.com");
21897+ assert_eq!(stored[0].1.message_id(), stored[1].1.message_id());
21898+ assert_eq!(stored[0].1.other_headers(), stored[1].1.other_headers());
21899+ headers_fn(&stored[0].1);
21900+ }
21901+
21902+ #[test]
21903+ fn test_list_requests_submission() {
21904+ use assert_cmd::Command;
21905+
21906+ let tmp_dir = TempDir::new().unwrap();
21907+
21908+ let conf_path = tmp_dir.path().join("conf.toml");
21909+ let db_path = tmp_dir.path().join("mpot.db");
21910+ let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8827").build();
21911+ let config = Configuration {
21912+ send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
21913+ db_path,
21914+ data_path: tmp_dir.path().to_path_buf(),
21915+ administrators: vec![],
21916+ };
21917+
21918+ let config_str = config.to_toml();
21919+
21920+ std::fs::write(&conf_path, config_str.as_bytes()).unwrap();
21921+
21922+ log::info!("Creating foo-chat@example.com mailing list.");
21923+ let post_policy;
21924+ let foo_chat = {
21925+ let db = Connection::open_or_create_db(config.clone())
21926+ .unwrap()
21927+ .trusted();
21928+
21929+ let foo_chat = db
21930+ .create_list(MailingList {
21931+ pk: 0,
21932+ name: "foobar chat".into(),
21933+ id: "foo-chat".into(),
21934+ address: "foo-chat@example.com".into(),
21935+ description: None,
21936+ topics: vec![],
21937+ archive_url: None,
21938+ })
21939+ .unwrap();
21940+
21941+ assert_eq!(foo_chat.pk(), 1);
21942+ post_policy = db
21943+ .set_list_post_policy(PostPolicy {
21944+ pk: -1,
21945+ list: foo_chat.pk(),
21946+ announce_only: false,
21947+ subscription_only: false,
21948+ approval_needed: false,
21949+ open: true,
21950+ custom: false,
21951+ })
21952+ .unwrap();
21953+ foo_chat
21954+ };
21955+
21956+ let headers_fn = |env: &melib::Envelope| {
21957+ let headers = env.other_headers();
21958+
21959+ assert_eq!(
21960+ headers.get(melib::HeaderName::LIST_ID),
21961+ Some(foo_chat.id_header().as_str())
21962+ );
21963+ assert_eq!(
21964+ headers
21965+ .get(melib::HeaderName::LIST_HELP)
21966+ .map(|header| header.to_string()),
21967+ foo_chat.help_header()
21968+ );
21969+ assert_eq!(
21970+ headers
21971+ .get(melib::HeaderName::LIST_POST)
21972+ .map(|header| header.to_string()),
21973+ foo_chat.post_header(Some(&post_policy))
21974+ );
21975+ };
21976+
21977+ log::info!("Running mpot flush-queue on empty out queue.");
21978+ let mut cmd = Command::cargo_bin("mpot").unwrap();
21979+ let output = cmd
21980+ .arg("-vv")
21981+ .arg("-c")
21982+ .arg(&conf_path)
21983+ .arg("flush-queue")
21984+ .output()
21985+ .unwrap()
21986+ .assert();
21987+ output.code(0).stderr(predicates::str::is_empty()).stdout(
21988+ predicate::eq("Queue out has 0 messages.")
21989+ .trim()
21990+ .normalize(),
21991+ );
21992+
21993+ let mut seq = 0; // for generated emails
21994+ log::info!("User Αλίκη sends to foo-chat+request with subject 'help'.");
21995+
21996+ {
21997+ let db = Connection::open_or_create_db(config).unwrap().trusted();
21998+
21999+ let mail = generate_mail("Αλίκη", "+request", "help", "", &mut seq);
22000+ let subenvelope = mailpot::melib::Envelope::from_bytes(mail.as_bytes(), None)
22001+ .expect("Could not parse message");
22002+ db.post(&subenvelope, mail.as_bytes(), /* dry_run */ false)
22003+ .unwrap();
22004+ let out_queue = db.queue(Queue::Out).unwrap();
22005+ assert_eq!(out_queue.len(), 1);
22006+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
22007+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
22008+ }
22009+
22010+ log::info!("Flush out queue, help reply should go to Αλίκη.");
22011+ let mut cmd = Command::cargo_bin("mpot").unwrap();
22012+ let output = cmd
22013+ .arg("-vv")
22014+ .arg("-c")
22015+ .arg(&conf_path)
22016+ .arg("flush-queue")
22017+ .output()
22018+ .unwrap()
22019+ .assert();
22020+ output.code(0).stdout(
22021+ predicate::eq("Queue out has 1 messages.")
22022+ .trim()
22023+ .normalize(),
22024+ );
22025+
22026+ /* Check that help email is correct */
22027+ let stored = std::mem::take(&mut *smtp_handler.stored.lock().unwrap());
22028+ assert_eq!(stored.len(), 1);
22029+ assert_eq!(stored[0].0, "=?UTF-8?B?zpHOu86vzrrOtw==?=@example.com");
22030+ assert_eq!(stored[0].1.subject(), "Help for foobar chat");
22031+ assert_eq!(
22032+ &stored[0].1.field_from_to_string(),
22033+ "foo-chat+request@example.com"
22034+ );
22035+ headers_fn(&stored[0].1);
22036+ }
22037 diff --git a/mailpot-http/.gitignore b/mailpot-http/.gitignore
22038new file mode 100644
22039index 0000000..856c436
22040--- /dev/null
22041+++ b/mailpot-http/.gitignore
22042 @@ -0,0 +1,2 @@
22043+ .env
22044+ config/local.json
22045 diff --git a/mailpot-http/Cargo.toml b/mailpot-http/Cargo.toml
22046new file mode 100644
22047index 0000000..20ab2ab
22048--- /dev/null
22049+++ b/mailpot-http/Cargo.toml
22050 @@ -0,0 +1,49 @@
22051+ [package]
22052+ name = "mailpot-http"
22053+ version = "0.1.1"
22054+ authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
22055+ edition = "2021"
22056+ license = "LICENSE"
22057+ readme = "README.md"
22058+ description = "mailing list manager"
22059+ repository = "https://github.com/meli/mailpot"
22060+ keywords = ["mail", "mailing-lists"]
22061+ categories = ["email"]
22062+ default-run = "mpot-http"
22063+
22064+ [[bin]]
22065+ name = "mpot-http"
22066+ path = "src/main.rs"
22067+
22068+ [dependencies]
22069+ async-trait = "0.1"
22070+ axum = { version = "0.6", features = ["headers"] }
22071+ axum-extra = { version = "^0.7", features = ["typed-routing"] }
22072+ #jsonwebtoken = "8.3"
22073+ bcrypt = "0.14"
22074+ config = "0.13"
22075+ http = "0.2"
22076+ lazy_static = "1.4"
22077+ log = "0.4"
22078+ mailpot = { version = "^0.1", path = "../mailpot" }
22079+ mailpot-web = { version = "^0.1", path = "../mailpot-web" }
22080+ serde = { version = "1", features = ["derive"] }
22081+ serde_json = "1"
22082+ stderrlog = { version = "^0.6" }
22083+ thiserror = "1"
22084+ tokio = { version = "1", features = ["full"] }
22085+ tower-http = { version = "0.4", features = [
22086+ "trace",
22087+ "compression-br",
22088+ "propagate-header",
22089+ "sensitive-headers",
22090+ "cors",
22091+ ] }
22092+
22093+ [dev-dependencies]
22094+ assert-json-diff = "2"
22095+ hyper = { version = "0.14" }
22096+ mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
22097+ reqwest = { version = "0.11", features = ["json"] }
22098+ tempfile = { version = "3.9" }
22099+ tower = { version = "^0.4" }
22100 diff --git a/mailpot-http/README.md b/mailpot-http/README.md
22101new file mode 100644
22102index 0000000..a89e59d
22103--- /dev/null
22104+++ b/mailpot-http/README.md
22105 @@ -0,0 +1,2 @@
22106+ # mailpot REST http server
22107+
22108 diff --git a/mailpot-http/config/default.json b/mailpot-http/config/default.json
22109new file mode 100644
22110index 0000000..fba51c5
22111--- /dev/null
22112+++ b/mailpot-http/config/default.json
22113 @@ -0,0 +1,12 @@
22114+ {
22115+ "environment": "development",
22116+ "server": {
22117+ "port": 8080
22118+ },
22119+ "auth": {
22120+ "secret": "secret"
22121+ },
22122+ "logger": {
22123+ "level": "debug"
22124+ }
22125+ }
22126 diff --git a/mailpot-http/config/production.json b/mailpot-http/config/production.json
22127new file mode 100644
22128index 0000000..0b731fa
22129--- /dev/null
22130+++ b/mailpot-http/config/production.json
22131 @@ -0,0 +1,6 @@
22132+ {
22133+ "environment": "production",
22134+ "logger": {
22135+ "level": "info"
22136+ }
22137+ }
22138 diff --git a/mailpot-http/config/test.json b/mailpot-http/config/test.json
22139new file mode 100644
22140index 0000000..a162f57
22141--- /dev/null
22142+++ b/mailpot-http/config/test.json
22143 @@ -0,0 +1,9 @@
22144+ {
22145+ "environment": "test",
22146+ "server": {
22147+ "port": 8088
22148+ },
22149+ "logger": {
22150+ "level": "error"
22151+ }
22152+ }
22153 diff --git a/mailpot-http/rustfmt.toml b/mailpot-http/rustfmt.toml
22154new file mode 120000
22155index 0000000..39f97b0
22156--- /dev/null
22157+++ b/mailpot-http/rustfmt.toml
22158 @@ -0,0 +1 @@
22159+ ../rustfmt.toml
22160\ No newline at end of file
22161 diff --git a/mailpot-http/src/errors.rs b/mailpot-http/src/errors.rs
22162new file mode 100644
22163index 0000000..7d78020
22164--- /dev/null
22165+++ b/mailpot-http/src/errors.rs
22166 @@ -0,0 +1,98 @@
22167+ use axum::{
22168+ http::StatusCode,
22169+ response::{IntoResponse, Response},
22170+ Json,
22171+ };
22172+ use bcrypt::BcryptError;
22173+ use serde_json::json;
22174+ use tokio::task::JoinError;
22175+
22176+ #[derive(thiserror::Error, Debug)]
22177+ #[error("...")]
22178+ pub enum Error {
22179+ #[error("Error parsing ObjectID {0}")]
22180+ ParseObjectID(String),
22181+
22182+ #[error("{0}")]
22183+ Authenticate(#[from] AuthenticateError),
22184+
22185+ #[error("{0}")]
22186+ BadRequest(#[from] BadRequest),
22187+
22188+ #[error("{0}")]
22189+ NotFound(#[from] NotFound),
22190+
22191+ #[error("{0}")]
22192+ RunSyncTask(#[from] JoinError),
22193+
22194+ #[error("{0}")]
22195+ HashPassword(#[from] BcryptError),
22196+
22197+ #[error("{0}")]
22198+ System(#[from] mailpot::Error),
22199+ }
22200+
22201+ impl Error {
22202+ fn get_codes(&self) -> (StatusCode, u16) {
22203+ match *self {
22204+ // 4XX Errors
22205+ Error::ParseObjectID(_) => (StatusCode::BAD_REQUEST, 40001),
22206+ Error::BadRequest(_) => (StatusCode::BAD_REQUEST, 40002),
22207+ Error::NotFound(_) => (StatusCode::NOT_FOUND, 40003),
22208+ Error::Authenticate(AuthenticateError::WrongCredentials) => {
22209+ (StatusCode::UNAUTHORIZED, 40004)
22210+ }
22211+ Error::Authenticate(AuthenticateError::InvalidToken) => {
22212+ (StatusCode::UNAUTHORIZED, 40005)
22213+ }
22214+ Error::Authenticate(AuthenticateError::Locked) => (StatusCode::LOCKED, 40006),
22215+
22216+ // 5XX Errors
22217+ Error::Authenticate(AuthenticateError::TokenCreation) => {
22218+ (StatusCode::INTERNAL_SERVER_ERROR, 5001)
22219+ }
22220+ Error::RunSyncTask(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5005),
22221+ Error::HashPassword(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5006),
22222+ Error::System(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5007),
22223+ }
22224+ }
22225+
22226+ pub fn bad_request() -> Self {
22227+ Error::BadRequest(BadRequest {})
22228+ }
22229+
22230+ pub fn not_found() -> Self {
22231+ Error::NotFound(NotFound {})
22232+ }
22233+ }
22234+
22235+ impl IntoResponse for Error {
22236+ fn into_response(self) -> Response {
22237+ let (status_code, code) = self.get_codes();
22238+ let message = self.to_string();
22239+ let body = Json(json!({ "code": code, "message": message }));
22240+
22241+ (status_code, body).into_response()
22242+ }
22243+ }
22244+
22245+ #[derive(thiserror::Error, Debug)]
22246+ #[error("...")]
22247+ pub enum AuthenticateError {
22248+ #[error("Wrong authentication credentials")]
22249+ WrongCredentials,
22250+ #[error("Failed to create authentication token")]
22251+ TokenCreation,
22252+ #[error("Invalid authentication credentials")]
22253+ InvalidToken,
22254+ #[error("User is locked")]
22255+ Locked,
22256+ }
22257+
22258+ #[derive(thiserror::Error, Debug)]
22259+ #[error("Bad Request")]
22260+ pub struct BadRequest {}
22261+
22262+ #[derive(thiserror::Error, Debug)]
22263+ #[error("Not found")]
22264+ pub struct NotFound {}
22265 diff --git a/mailpot-http/src/lib.rs b/mailpot-http/src/lib.rs
22266new file mode 100644
22267index 0000000..3dd161a
22268--- /dev/null
22269+++ b/mailpot-http/src/lib.rs
22270 @@ -0,0 +1,51 @@
22271+ /*
22272+ * This file is part of mailpot
22273+ *
22274+ * Copyright 2020 - Manos Pitsidianakis
22275+ *
22276+ * This program is free software: you can redistribute it and/or modify
22277+ * it under the terms of the GNU Affero General Public License as
22278+ * published by the Free Software Foundation, either version 3 of the
22279+ * License, or (at your option) any later version.
22280+ *
22281+ * This program is distributed in the hope that it will be useful,
22282+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
22283+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22284+ * GNU Affero General Public License for more details.
22285+ *
22286+ * You should have received a copy of the GNU Affero General Public License
22287+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
22288+ */
22289+
22290+ pub use std::{net::SocketAddr, sync::Arc};
22291+
22292+ pub use axum::Router;
22293+ pub use http::header;
22294+ pub use log::{debug, info, trace};
22295+ pub use mailpot::{models::*, Configuration, Connection};
22296+ pub mod errors;
22297+ pub mod routes;
22298+ pub mod settings;
22299+
22300+ use tower_http::{
22301+ compression::CompressionLayer, cors::CorsLayer, propagate_header::PropagateHeaderLayer,
22302+ sensitive_headers::SetSensitiveHeadersLayer,
22303+ };
22304+
22305+ pub fn create_app(conf: Arc<Configuration>) -> Router {
22306+ Router::new()
22307+ .with_state(conf.clone())
22308+ .merge(Router::new().nest("/v1", Router::new().merge(routes::list::create_route(conf))))
22309+ .layer(SetSensitiveHeadersLayer::new(std::iter::once(
22310+ header::AUTHORIZATION,
22311+ )))
22312+ // Compress responses
22313+ .layer(CompressionLayer::new())
22314+ // Propagate `X-Request-Id`s from requests to responses
22315+ .layer(PropagateHeaderLayer::new(header::HeaderName::from_static(
22316+ "x-request-id",
22317+ )))
22318+ // CORS configuration. This should probably be more restrictive in
22319+ // production.
22320+ .layer(CorsLayer::permissive())
22321+ }
22322 diff --git a/mailpot-http/src/main.rs b/mailpot-http/src/main.rs
22323new file mode 100644
22324index 0000000..704e406
22325--- /dev/null
22326+++ b/mailpot-http/src/main.rs
22327 @@ -0,0 +1,32 @@
22328+ use mailpot_http::{settings::SETTINGS, *};
22329+
22330+ use crate::create_app;
22331+
22332+ #[tokio::main]
22333+ async fn main() {
22334+ let config_path = std::env::args()
22335+ .nth(1)
22336+ .expect("Expected configuration file path as first argument.");
22337+ #[cfg(test)]
22338+ let verbosity = log::LevelFilter::Trace;
22339+ #[cfg(not(test))]
22340+ let verbosity = log::LevelFilter::Info;
22341+ stderrlog::new()
22342+ .quiet(false)
22343+ .verbosity(verbosity)
22344+ .show_module_names(true)
22345+ .timestamp(stderrlog::Timestamp::Millisecond)
22346+ .init()
22347+ .unwrap();
22348+ let conf = Arc::new(Configuration::from_file(config_path).unwrap());
22349+ let app = create_app(conf);
22350+
22351+ let port = SETTINGS.server.port;
22352+ let address = SocketAddr::from(([127, 0, 0, 1], port));
22353+
22354+ info!("Server listening on {}", &address);
22355+ axum::Server::bind(&address)
22356+ .serve(app.into_make_service())
22357+ .await
22358+ .expect("Failed to start server");
22359+ }
22360 diff --git a/mailpot-http/src/routes/list.rs b/mailpot-http/src/routes/list.rs
22361new file mode 100644
22362index 0000000..7fdfaad
22363--- /dev/null
22364+++ b/mailpot-http/src/routes/list.rs
22365 @@ -0,0 +1,417 @@
22366+ use std::sync::Arc;
22367+
22368+ pub use axum::extract::{Path, Query, State};
22369+ use axum::{http::StatusCode, Json, Router};
22370+ use mailpot_web::{typed_paths::*, ResponseError, RouterExt, TypedPath};
22371+ use serde::{Deserialize, Serialize};
22372+
22373+ use crate::*;
22374+
22375+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
22376+ #[typed_path("/list/")]
22377+ pub struct ListsPath;
22378+
22379+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
22380+ #[typed_path("/list/:id/owner/")]
22381+ pub struct ListOwnerPath(pub ListPathIdentifier);
22382+
22383+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
22384+ #[typed_path("/list/:id/subscription/")]
22385+ pub struct ListSubscriptionPath(pub ListPathIdentifier);
22386+
22387+ pub fn create_route(conf: Arc<Configuration>) -> Router {
22388+ Router::new()
22389+ .typed_get(all_lists)
22390+ .typed_post(new_list)
22391+ .typed_get(get_list)
22392+ .typed_post({
22393+ move |_: ListPath| async move {
22394+ Err::<(), ResponseError>(mailpot_web::ResponseError::new(
22395+ "Invalid method".to_string(),
22396+ StatusCode::BAD_REQUEST,
22397+ ))
22398+ }
22399+ })
22400+ .typed_get(get_list_owner)
22401+ .typed_post(new_list_owner)
22402+ .typed_get(get_list_subs)
22403+ .typed_post(new_list_sub)
22404+ .with_state(conf)
22405+ }
22406+
22407+ async fn get_list(
22408+ ListPath(id): ListPath,
22409+ State(state): State<Arc<Configuration>>,
22410+ ) -> Result<Json<MailingList>, ResponseError> {
22411+ let db = Connection::open_db(Configuration::clone(&state))?;
22412+ let Some(list) = (match id {
22413+ ListPathIdentifier::Pk(id) => db.list(id)?,
22414+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
22415+ }) else {
22416+ return Err(mailpot_web::ResponseError::new(
22417+ "Not found".to_string(),
22418+ StatusCode::NOT_FOUND,
22419+ ));
22420+ };
22421+ Ok(Json(list.into_inner()))
22422+ }
22423+
22424+ async fn all_lists(
22425+ _: ListsPath,
22426+ Query(GetRequest {
22427+ filter: _,
22428+ count,
22429+ page,
22430+ }): Query<GetRequest>,
22431+ State(state): State<Arc<Configuration>>,
22432+ ) -> Result<Json<GetResponse>, ResponseError> {
22433+ let db = Connection::open_db(Configuration::clone(&state))?;
22434+ let lists_values = db.lists()?;
22435+ let page = page.unwrap_or(0);
22436+ let Some(count) = count else {
22437+ let mut stmt = db.connection.prepare("SELECT count(*) FROM list;")?;
22438+ return Ok(Json(GetResponse {
22439+ entries: vec![],
22440+ total: stmt.query_row([], |row| {
22441+ let count: usize = row.get(0)?;
22442+ Ok(count)
22443+ })?,
22444+ start: 0,
22445+ }));
22446+ };
22447+ let offset = page * count;
22448+ let res: Vec<_> = lists_values
22449+ .into_iter()
22450+ .skip(offset)
22451+ .take(count)
22452+ .map(DbVal::into_inner)
22453+ .collect();
22454+
22455+ Ok(Json(GetResponse {
22456+ total: res.len(),
22457+ start: offset,
22458+ entries: res,
22459+ }))
22460+ }
22461+
22462+ async fn new_list(
22463+ _: ListsPath,
22464+ State(_state): State<Arc<Configuration>>,
22465+ //Json(_body): Json<GetRequest>,
22466+ ) -> Result<Json<()>, ResponseError> {
22467+ // TODO create new list
22468+ Err(mailpot_web::ResponseError::new(
22469+ "Not allowed".to_string(),
22470+ StatusCode::UNAUTHORIZED,
22471+ ))
22472+ }
22473+
22474+ #[derive(Debug, Serialize, Deserialize)]
22475+ enum GetFilter {
22476+ Pk(i64),
22477+ Address(String),
22478+ Id(String),
22479+ Name(String),
22480+ }
22481+
22482+ #[derive(Debug, Serialize, Deserialize)]
22483+ struct GetRequest {
22484+ filter: Option<GetFilter>,
22485+ count: Option<usize>,
22486+ page: Option<usize>,
22487+ }
22488+
22489+ #[derive(Debug, Serialize, Deserialize)]
22490+ struct GetResponse {
22491+ entries: Vec<MailingList>,
22492+ total: usize,
22493+ start: usize,
22494+ }
22495+
22496+ async fn get_list_owner(
22497+ ListOwnerPath(id): ListOwnerPath,
22498+ State(state): State<Arc<Configuration>>,
22499+ ) -> Result<Json<Vec<ListOwner>>, ResponseError> {
22500+ let db = Connection::open_db(Configuration::clone(&state))?;
22501+ let owners = match id {
22502+ ListPathIdentifier::Pk(id) => db.list_owners(id)?,
22503+ ListPathIdentifier::Id(id) => {
22504+ if let Some(owners) = db.list_by_id(id)?.map(|l| db.list_owners(l.pk())) {
22505+ owners?
22506+ } else {
22507+ return Err(mailpot_web::ResponseError::new(
22508+ "Not found".to_string(),
22509+ StatusCode::NOT_FOUND,
22510+ ));
22511+ }
22512+ }
22513+ };
22514+ Ok(Json(owners.into_iter().map(DbVal::into_inner).collect()))
22515+ }
22516+
22517+ async fn new_list_owner(
22518+ ListOwnerPath(_id): ListOwnerPath,
22519+ State(_state): State<Arc<Configuration>>,
22520+ //Json(_body): Json<GetRequest>,
22521+ ) -> Result<Json<Vec<ListOwner>>, ResponseError> {
22522+ Err(mailpot_web::ResponseError::new(
22523+ "Not allowed".to_string(),
22524+ StatusCode::UNAUTHORIZED,
22525+ ))
22526+ }
22527+
22528+ async fn get_list_subs(
22529+ ListSubscriptionPath(id): ListSubscriptionPath,
22530+ State(state): State<Arc<Configuration>>,
22531+ ) -> Result<Json<Vec<ListSubscription>>, ResponseError> {
22532+ let db = Connection::open_db(Configuration::clone(&state))?;
22533+ let subs = match id {
22534+ ListPathIdentifier::Pk(id) => db.list_subscriptions(id)?,
22535+ ListPathIdentifier::Id(id) => {
22536+ if let Some(v) = db.list_by_id(id)?.map(|l| db.list_subscriptions(l.pk())) {
22537+ v?
22538+ } else {
22539+ return Err(mailpot_web::ResponseError::new(
22540+ "Not found".to_string(),
22541+ StatusCode::NOT_FOUND,
22542+ ));
22543+ }
22544+ }
22545+ };
22546+ Ok(Json(subs.into_iter().map(DbVal::into_inner).collect()))
22547+ }
22548+
22549+ async fn new_list_sub(
22550+ ListSubscriptionPath(_id): ListSubscriptionPath,
22551+ State(_state): State<Arc<Configuration>>,
22552+ //Json(_body): Json<GetRequest>,
22553+ ) -> Result<Json<ListSubscription>, ResponseError> {
22554+ Err(mailpot_web::ResponseError::new(
22555+ "Not allowed".to_string(),
22556+ StatusCode::UNAUTHORIZED,
22557+ ))
22558+ }
22559+
22560+ #[cfg(test)]
22561+ mod tests {
22562+
22563+ use axum::{
22564+ body::Body,
22565+ http::{method::Method, Request, StatusCode},
22566+ };
22567+ use mailpot::{models::*, Configuration, Connection, SendMail};
22568+ use mailpot_tests::init_stderr_logging;
22569+ use serde_json::json;
22570+ use tempfile::TempDir;
22571+ use tower::ServiceExt; // for `oneshot` and `ready`
22572+
22573+ use super::*;
22574+
22575+ #[tokio::test]
22576+ async fn test_list_router() {
22577+ init_stderr_logging();
22578+
22579+ let tmp_dir = TempDir::new().unwrap();
22580+
22581+ let db_path = tmp_dir.path().join("mpot.db");
22582+ std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
22583+ let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
22584+ #[allow(clippy::permissions_set_readonly_false)]
22585+ perms.set_readonly(false);
22586+ std::fs::set_permissions(&db_path, perms).unwrap();
22587+ let config = Configuration {
22588+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
22589+ db_path,
22590+ data_path: tmp_dir.path().to_path_buf(),
22591+ administrators: vec![],
22592+ };
22593+
22594+ let db = Connection::open_db(config.clone()).unwrap().trusted();
22595+ assert!(!db.lists().unwrap().is_empty());
22596+ let foo_chat = MailingList {
22597+ pk: 1,
22598+ name: "foobar chat".into(),
22599+ id: "foo-chat".into(),
22600+ address: "foo-chat@example.com".into(),
22601+ topics: vec![],
22602+ description: None,
22603+ archive_url: None,
22604+ };
22605+ assert_eq!(&db.lists().unwrap().remove(0).into_inner(), &foo_chat);
22606+ drop(db);
22607+
22608+ let config = Arc::new(config);
22609+
22610+ // ------------------------------------------------------------
22611+ // all_lists() get total
22612+
22613+ let response = crate::create_app(config.clone())
22614+ .oneshot(
22615+ Request::builder()
22616+ .uri("/v1/list/")
22617+ .body(Body::empty())
22618+ .unwrap(),
22619+ )
22620+ .await
22621+ .unwrap();
22622+
22623+ assert_eq!(response.status(), StatusCode::OK);
22624+
22625+ let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
22626+ let r: GetResponse = serde_json::from_slice(&body).unwrap();
22627+
22628+ assert_eq!(&r.entries, &[]);
22629+ assert_eq!(r.total, 1);
22630+ assert_eq!(r.start, 0);
22631+
22632+ // ------------------------------------------------------------
22633+ // all_lists() with count
22634+
22635+ let response = crate::create_app(config.clone())
22636+ .oneshot(
22637+ Request::builder()
22638+ .uri("/v1/list/?count=20")
22639+ .body(Body::empty())
22640+ .unwrap(),
22641+ )
22642+ .await
22643+ .unwrap();
22644+ assert_eq!(response.status(), StatusCode::OK);
22645+
22646+ let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
22647+ let r: GetResponse = serde_json::from_slice(&body).unwrap();
22648+
22649+ assert_eq!(&r.entries, &[foo_chat.clone()]);
22650+ assert_eq!(r.total, 1);
22651+ assert_eq!(r.start, 0);
22652+
22653+ // ------------------------------------------------------------
22654+ // new_list()
22655+
22656+ let response = crate::create_app(config.clone())
22657+ .oneshot(
22658+ Request::builder()
22659+ .uri("/v1/list/")
22660+ .header("Content-Type", "application/json")
22661+ .method(Method::POST)
22662+ .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap()))
22663+ .unwrap(),
22664+ )
22665+ .await
22666+ .unwrap();
22667+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
22668+
22669+ // ------------------------------------------------------------
22670+ // get_list()
22671+
22672+ let response = crate::create_app(config.clone())
22673+ .oneshot(
22674+ Request::builder()
22675+ .uri("/v1/list/1/")
22676+ .header("Content-Type", "application/json")
22677+ .method(Method::GET)
22678+ .body(Body::empty())
22679+ .unwrap(),
22680+ )
22681+ .await
22682+ .unwrap();
22683+ assert_eq!(response.status(), StatusCode::OK);
22684+ let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
22685+ let r: MailingList = serde_json::from_slice(&body).unwrap();
22686+ assert_eq!(&r, &foo_chat);
22687+
22688+ // ------------------------------------------------------------
22689+ // get_list_subs()
22690+
22691+ let response = crate::create_app(config.clone())
22692+ .oneshot(
22693+ Request::builder()
22694+ .uri("/v1/list/1/subscription/")
22695+ .header("Content-Type", "application/json")
22696+ .method(Method::GET)
22697+ .body(Body::empty())
22698+ .unwrap(),
22699+ )
22700+ .await
22701+ .unwrap();
22702+ assert_eq!(response.status(), StatusCode::OK);
22703+ let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
22704+ let r: Vec<ListSubscription> = serde_json::from_slice(&body).unwrap();
22705+ assert_eq!(
22706+ &r,
22707+ &[ListSubscription {
22708+ pk: 1,
22709+ list: 1,
22710+ address: "user@example.com".to_string(),
22711+ name: Some("Name".to_string()),
22712+ account: Some(1),
22713+ enabled: true,
22714+ verified: false,
22715+ digest: false,
22716+ hide_address: false,
22717+ receive_duplicates: true,
22718+ receive_own_posts: false,
22719+ receive_confirmation: true
22720+ }]
22721+ );
22722+
22723+ // ------------------------------------------------------------
22724+ // new_list_sub()
22725+
22726+ let response = crate::create_app(config.clone())
22727+ .oneshot(
22728+ Request::builder()
22729+ .uri("/v1/list/1/subscription/")
22730+ .header("Content-Type", "application/json")
22731+ .method(Method::POST)
22732+ .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap()))
22733+ .unwrap(),
22734+ )
22735+ .await
22736+ .unwrap();
22737+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
22738+
22739+ // ------------------------------------------------------------
22740+ // get_list_owner()
22741+
22742+ let response = crate::create_app(config.clone())
22743+ .oneshot(
22744+ Request::builder()
22745+ .uri("/v1/list/1/owner/")
22746+ .header("Content-Type", "application/json")
22747+ .method(Method::GET)
22748+ .body(Body::empty())
22749+ .unwrap(),
22750+ )
22751+ .await
22752+ .unwrap();
22753+ assert_eq!(response.status(), StatusCode::OK);
22754+ let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
22755+ let r: Vec<ListOwner> = serde_json::from_slice(&body).unwrap();
22756+ assert_eq!(
22757+ &r,
22758+ &[ListOwner {
22759+ pk: 1,
22760+ list: 1,
22761+ address: "user@example.com".into(),
22762+ name: None
22763+ }]
22764+ );
22765+
22766+ // ------------------------------------------------------------
22767+ // new_list_owner()
22768+
22769+ let response = crate::create_app(config.clone())
22770+ .oneshot(
22771+ Request::builder()
22772+ .uri("/v1/list/1/owner/")
22773+ .header("Content-Type", "application/json")
22774+ .method(Method::POST)
22775+ .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap()))
22776+ .unwrap(),
22777+ )
22778+ .await
22779+ .unwrap();
22780+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
22781+ }
22782+ }
22783 diff --git a/mailpot-http/src/routes/mod.rs b/mailpot-http/src/routes/mod.rs
22784new file mode 100644
22785index 0000000..d17e233
22786--- /dev/null
22787+++ b/mailpot-http/src/routes/mod.rs
22788 @@ -0,0 +1 @@
22789+ pub mod list;
22790 diff --git a/mailpot-http/src/settings.rs b/mailpot-http/src/settings.rs
22791new file mode 100644
22792index 0000000..b1ef467
22793--- /dev/null
22794+++ b/mailpot-http/src/settings.rs
22795 @@ -0,0 +1,61 @@
22796+ use std::{env, fmt};
22797+
22798+ use config::{Config, ConfigError, Environment, File};
22799+ use lazy_static::lazy_static;
22800+ use serde::Deserialize;
22801+
22802+ lazy_static! {
22803+ pub static ref SETTINGS: Settings = Settings::new().expect("Failed to setup settings");
22804+ }
22805+
22806+ #[derive(Debug, Clone, Deserialize)]
22807+ pub struct Server {
22808+ pub port: u16,
22809+ }
22810+
22811+ #[derive(Debug, Clone, Deserialize)]
22812+ pub struct Logger {
22813+ pub level: String,
22814+ }
22815+
22816+ #[derive(Debug, Clone, Deserialize)]
22817+ pub struct Auth {
22818+ pub secret: String,
22819+ }
22820+
22821+ #[derive(Debug, Clone, Deserialize)]
22822+ pub struct Settings {
22823+ pub environment: String,
22824+ pub server: Server,
22825+ pub logger: Logger,
22826+ pub auth: Auth,
22827+ }
22828+
22829+ impl Settings {
22830+ pub fn new() -> Result<Self, ConfigError> {
22831+ let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
22832+
22833+ let mut builder = Config::builder()
22834+ .add_source(File::with_name("config/default"))
22835+ .add_source(File::with_name(&format!("config/{run_mode}")).required(false))
22836+ .add_source(File::with_name("config/local").required(false))
22837+ .add_source(Environment::default().separator("__"));
22838+
22839+ // Some cloud services like Heroku exposes a randomly assigned port in
22840+ // the PORT env var and there is no way to change the env var name.
22841+ if let Ok(port) = env::var("PORT") {
22842+ builder = builder.set_override("server.port", port)?;
22843+ }
22844+
22845+ builder
22846+ .build()?
22847+ // Deserialize (and thus freeze) the entire configuration.
22848+ .try_deserialize()
22849+ }
22850+ }
22851+
22852+ impl fmt::Display for Server {
22853+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
22854+ write!(f, "http://localhost:{}", &self.port)
22855+ }
22856+ }
22857 diff --git a/mailpot-tests/Cargo.toml b/mailpot-tests/Cargo.toml
22858index 1dd5622..be440b4 100644
22859--- a/mailpot-tests/Cargo.toml
22860+++ b/mailpot-tests/Cargo.toml
22861 @@ -13,7 +13,7 @@ publish = false
22862 assert_cmd = "2"
22863 log = "0.4"
22864 mailin-embedded = { version = "0.7", features = ["rtls"] }
22865- mailpot = { version = "^0.1", path = "../core" }
22866+ mailpot = { version = "^0.1", path = "../mailpot" }
22867 predicates = "3"
22868 stderrlog = { version = "^0.6" }
22869 tempfile = { version = "3.9" }
22870 diff --git a/mailpot-web/.gitignore b/mailpot-web/.gitignore
22871new file mode 100644
22872index 0000000..3523f09
22873--- /dev/null
22874+++ b/mailpot-web/.gitignore
22875 @@ -0,0 +1 @@
22876+ src/minijinja_utils/compressed.data
22877 diff --git a/mailpot-web/Cargo.toml b/mailpot-web/Cargo.toml
22878new file mode 100644
22879index 0000000..6e80a2e
22880--- /dev/null
22881+++ b/mailpot-web/Cargo.toml
22882 @@ -0,0 +1,59 @@
22883+ [package]
22884+ name = "mailpot-web"
22885+ version = "0.1.1"
22886+ authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
22887+ edition = "2021"
22888+ license = "LICENSE"
22889+ readme = "README.md"
22890+ description = "mailing list manager"
22891+ repository = "https://github.com/meli/mailpot"
22892+ keywords = ["mail", "mailing-lists"]
22893+ categories = ["email"]
22894+
22895+ [[bin]]
22896+ name = "mpot-web"
22897+ path = "src/main.rs"
22898+ doc-scrape-examples = true
22899+
22900+ [features]
22901+ default = ["ssh-key"]
22902+ ssh-key = ["dep:ssh-key"]
22903+
22904+ [dependencies]
22905+ axum = { version = "^0.6" }
22906+ axum-extra = { version = "^0.7", features = ["typed-routing"] }
22907+ axum-login = { version = "^0.5" }
22908+ axum-sessions = { version = "^0.5" }
22909+ build-info = { version = "0.0.31" }
22910+ cfg-if = { version = "1" }
22911+ chrono = { version = "^0.4" }
22912+ convert_case = { version = "^0.4" }
22913+ dyn-clone = { version = "^1" }
22914+ eyre = { version = "0.6" }
22915+ http = "0.2"
22916+ indexmap = { version = "1.9" }
22917+ lazy_static = "^1.4"
22918+ mailpot = { version = "^0.1", path = "../mailpot" }
22919+ minijinja = { version = "0.31.0", features = ["source", ] }
22920+ percent-encoding = { version = "^2.1" }
22921+ rand = { version = "^0.8", features = ["min_const_gen"] }
22922+ serde = { version = "^1", features = ["derive", ] }
22923+ serde_json = "^1"
22924+ ssh-key = { version = "0.6.2", optional = true, features = ["crypto"] }
22925+ stderrlog = { version = "^0.6" }
22926+ tempfile = { version = "3.9" }
22927+ tokio = { version = "1", features = ["full"] }
22928+ tower-http = { version = "^0.3" }
22929+ tower-service = { version = "^0.3" }
22930+ zstd = { version = "0.12", default-features = false }
22931+
22932+ [dev-dependencies]
22933+ hyper = { version = "0.14" }
22934+ mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
22935+ serde_urlencoded = { version = "^0.7" }
22936+ tempfile = { version = "3.9" }
22937+ tower = { version = "^0.4" }
22938+
22939+ [build-dependencies]
22940+ build-info-build = { version = "0.0.31" }
22941+ zstd = { version = "0.12", default-features = false }
22942 diff --git a/mailpot-web/README.md b/mailpot-web/README.md
22943new file mode 100644
22944index 0000000..c54e80c
22945--- /dev/null
22946+++ b/mailpot-web/README.md
22947 @@ -0,0 +1,20 @@
22948+ # mailpot web server
22949+
22950+ ```shell
22951+ cargo run --bin mpot-web -- /path/to/conf.toml
22952+ ```
22953+
22954+ Templates are compressed with `zstd` and bundled in the binary.
22955+
22956+ ## Configuration
22957+
22958+ By default, the server listens on `0.0.0.0:3000`.
22959+ The following environment variables can be defined to configure various settings:
22960+
22961+ - `HOSTNAME`, default `0.0.0.0`.
22962+ - `PORT`, default `3000`.
22963+ - `PUBLIC_URL`, default `lists.mailpot.rs`.
22964+ - `SITE_TITLE`, default `mailing list archive`.
22965+ - `SITE_SUBTITLE`, default empty.
22966+ - `ROOT_URL_PREFIX`, default empty.
22967+ - `SSH_NAMESPACE`, default `lists.mailpot.rs`.
22968 diff --git a/mailpot-web/build.rs b/mailpot-web/build.rs
22969new file mode 100644
22970index 0000000..5008bdc
22971--- /dev/null
22972+++ b/mailpot-web/build.rs
22973 @@ -0,0 +1,105 @@
22974+ /*
22975+ * This file is part of mailpot
22976+ *
22977+ * Copyright 2020 - Manos Pitsidianakis
22978+ *
22979+ * This program is free software: you can redistribute it and/or modify
22980+ * it under the terms of the GNU Affero General Public License as
22981+ * published by the Free Software Foundation, either version 3 of the
22982+ * License, or (at your option) any later version.
22983+ *
22984+ * This program is distributed in the hope that it will be useful,
22985+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
22986+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22987+ * GNU Affero General Public License for more details.
22988+ *
22989+ * You should have received a copy of the GNU Affero General Public License
22990+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
22991+ */
22992+
22993+ fn commit_sha() {
22994+ build_info_build::build_script();
22995+
22996+ if let Ok(s) = std::fs::read_to_string(".cargo_vcs_info.json") {
22997+ const KEY: &str = "\"sha1\":";
22998+
22999+ fn find_tail<'str>(str: &'str str, tok: &str) -> Option<&'str str> {
23000+ let i = str.find(tok)?;
23001+ Some(&str[(i + tok.len())..])
23002+ }
23003+
23004+ if let Some(mut tail) = find_tail(&s, KEY) {
23005+ while !tail.starts_with('"') && !tail.is_empty() {
23006+ tail = &tail[1..];
23007+ }
23008+ if !tail.is_empty() {
23009+ // skip "
23010+ tail = &tail[1..];
23011+ if let Some(end) = find_tail(tail, "\"") {
23012+ let end = tail.len() - end.len() - 1;
23013+ println!("cargo:rustc-env=PACKAGE_GIT_SHA={}", &tail[..end]);
23014+ }
23015+ }
23016+ }
23017+ }
23018+ }
23019+
23020+ fn main() -> Result<(), Box<dyn std::error::Error>> {
23021+ // Embed HTML templates as zstd compressed byte slices into binary.
23022+ // [tag:embed_templates]
23023+
23024+ use std::{
23025+ fs::{create_dir_all, read_dir, OpenOptions},
23026+ io::{Read, Write},
23027+ path::PathBuf,
23028+ };
23029+ create_dir_all("./src/minijinja_utils")?;
23030+ let mut compressed = OpenOptions::new()
23031+ .write(true)
23032+ .create(true)
23033+ .truncate(true)
23034+ .open("./src/minijinja_utils/compressed.data")?;
23035+
23036+ println!("cargo:rerun-if-changed=./src/templates");
23037+ println!("cargo:rerun-if-changed=./src/minijinja_utils/compressed.rs");
23038+
23039+ let mut templates: Vec<(String, PathBuf)> = vec![];
23040+ let root_prefix: PathBuf = "./src/templates/".into();
23041+ let mut dirs: Vec<PathBuf> = vec!["./src/templates/".into()];
23042+ while let Some(dir) = dirs.pop() {
23043+ for entry in read_dir(dir)? {
23044+ let entry = entry?;
23045+ let path = entry.path();
23046+ if path.is_dir() {
23047+ dirs.push(path);
23048+ } else if path.extension().map(|s| s == "html").unwrap_or(false) {
23049+ templates.push((path.strip_prefix(&root_prefix)?.display().to_string(), path));
23050+ }
23051+ }
23052+ }
23053+
23054+ compressed.write_all(b"&[")?;
23055+ for (name, template_path) in templates {
23056+ let mut templ = OpenOptions::new()
23057+ .write(false)
23058+ .create(false)
23059+ .read(true)
23060+ .open(&template_path)?;
23061+ let mut templ_bytes = vec![];
23062+ let mut compressed_bytes = vec![];
23063+ let mut enc = zstd::stream::write::Encoder::new(&mut compressed_bytes, 21)?;
23064+ enc.include_checksum(true)?;
23065+ templ.read_to_end(&mut templ_bytes)?;
23066+ enc.write_all(&templ_bytes)?;
23067+ enc.finish()?;
23068+ compressed.write_all(b"(\"")?;
23069+ compressed.write_all(name.as_bytes())?;
23070+ compressed.write_all(b"\",&")?;
23071+ compressed.write_all(format!("{:?}", compressed_bytes).as_bytes())?;
23072+ compressed.write_all(b"),")?;
23073+ }
23074+ compressed.write_all(b"]")?;
23075+
23076+ commit_sha();
23077+ Ok(())
23078+ }
23079 diff --git a/mailpot-web/rustfmt.toml b/mailpot-web/rustfmt.toml
23080new file mode 120000
23081index 0000000..39f97b0
23082--- /dev/null
23083+++ b/mailpot-web/rustfmt.toml
23084 @@ -0,0 +1 @@
23085+ ../rustfmt.toml
23086\ No newline at end of file
23087 diff --git a/mailpot-web/src/auth.rs b/mailpot-web/src/auth.rs
23088new file mode 100644
23089index 0000000..5da49ae
23090--- /dev/null
23091+++ b/mailpot-web/src/auth.rs
23092 @@ -0,0 +1,844 @@
23093+ /*
23094+ * This file is part of mailpot
23095+ *
23096+ * Copyright 2020 - Manos Pitsidianakis
23097+ *
23098+ * This program is free software: you can redistribute it and/or modify
23099+ * it under the terms of the GNU Affero General Public License as
23100+ * published by the Free Software Foundation, either version 3 of the
23101+ * License, or (at your option) any later version.
23102+ *
23103+ * This program is distributed in the hope that it will be useful,
23104+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
23105+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23106+ * GNU Affero General Public License for more details.
23107+ *
23108+ * You should have received a copy of the GNU Affero General Public License
23109+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
23110+ */
23111+
23112+ use std::{borrow::Cow, process::Stdio};
23113+
23114+ use tempfile::NamedTempFile;
23115+ use tokio::{fs::File, io::AsyncWriteExt, process::Command};
23116+
23117+ use super::*;
23118+
23119+ const TOKEN_KEY: &str = "ssh_challenge";
23120+ const EXPIRY_IN_SECS: i64 = 6 * 60;
23121+
23122+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq, PartialOrd)]
23123+ pub enum Role {
23124+ User,
23125+ Admin,
23126+ }
23127+
23128+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
23129+ pub struct User {
23130+ /// SSH signature.
23131+ pub ssh_signature: String,
23132+ /// User role.
23133+ pub role: Role,
23134+ /// Database primary key.
23135+ pub pk: i64,
23136+ /// Accounts's display name, optional.
23137+ pub name: Option<String>,
23138+ /// Account's e-mail address.
23139+ pub address: String,
23140+ /// GPG public key.
23141+ pub public_key: Option<String>,
23142+ /// SSH public key.
23143+ pub password: String,
23144+ /// Whether this account is enabled.
23145+ pub enabled: bool,
23146+ }
23147+
23148+ impl AuthUser<i64, Role> for User {
23149+ fn get_id(&self) -> i64 {
23150+ self.pk
23151+ }
23152+
23153+ fn get_password_hash(&self) -> SecretVec<u8> {
23154+ SecretVec::new(self.ssh_signature.clone().into())
23155+ }
23156+
23157+ fn get_role(&self) -> Option<Role> {
23158+ Some(self.role)
23159+ }
23160+ }
23161+
23162+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
23163+ pub struct AuthFormPayload {
23164+ pub address: String,
23165+ pub password: String,
23166+ }
23167+
23168+ pub async fn ssh_signin(
23169+ _: LoginPath,
23170+ mut session: WritableSession,
23171+ Query(next): Query<Next>,
23172+ auth: AuthContext,
23173+ State(state): State<Arc<AppState>>,
23174+ ) -> impl IntoResponse {
23175+ if auth.current_user.is_some() {
23176+ if let Err(err) = session.add_message(Message {
23177+ message: "You are already logged in.".into(),
23178+ level: Level::Info,
23179+ }) {
23180+ return err.into_response();
23181+ }
23182+ return next
23183+ .or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri()))
23184+ .into_response();
23185+ }
23186+ if next.next.is_some() {
23187+ if let Err(err) = session.add_message(Message {
23188+ message: "You need to be logged in to access this page.".into(),
23189+ level: Level::Info,
23190+ }) {
23191+ return err.into_response();
23192+ };
23193+ }
23194+
23195+ let now: i64 = chrono::offset::Utc::now().timestamp();
23196+
23197+ let prev_token = if let Some(tok) = session.get::<(String, i64)>(TOKEN_KEY) {
23198+ let timestamp: i64 = tok.1;
23199+ if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
23200+ session.remove(TOKEN_KEY);
23201+ None
23202+ } else {
23203+ Some(tok)
23204+ }
23205+ } else {
23206+ None
23207+ };
23208+
23209+ let (token, timestamp): (String, i64) = prev_token.map_or_else(
23210+ || {
23211+ use rand::{distributions::Alphanumeric, thread_rng, Rng};
23212+
23213+ let mut rng = thread_rng();
23214+ let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
23215+ println!("Random chars: {}", chars);
23216+ session.insert(TOKEN_KEY, (&chars, now)).unwrap();
23217+ (chars, now)
23218+ },
23219+ |tok| tok,
23220+ );
23221+ let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
23222+
23223+ let crumbs = vec![
23224+ Crumb {
23225+ label: "Home".into(),
23226+ url: "/".into(),
23227+ },
23228+ Crumb {
23229+ label: "Sign in".into(),
23230+ url: LoginPath.to_crumb(),
23231+ },
23232+ ];
23233+
23234+ let context = minijinja::context! {
23235+ namespace => &state.public_url,
23236+ page_title => "Log in",
23237+ ssh_challenge => token,
23238+ timeout_left => timeout_left,
23239+ current_user => auth.current_user,
23240+ messages => session.drain_messages(),
23241+ crumbs => crumbs,
23242+ };
23243+ Html(
23244+ TEMPLATES
23245+ .get_template("auth.html")
23246+ .unwrap()
23247+ .render(context)
23248+ .unwrap_or_else(|err| err.to_string()),
23249+ )
23250+ .into_response()
23251+ }
23252+
23253+ #[allow(non_snake_case)]
23254+ pub async fn ssh_signin_POST(
23255+ _: LoginPath,
23256+ mut session: WritableSession,
23257+ Query(next): Query<Next>,
23258+ mut auth: AuthContext,
23259+ Form(payload): Form<AuthFormPayload>,
23260+ state: Arc<AppState>,
23261+ ) -> Result<Redirect, ResponseError> {
23262+ if auth.current_user.as_ref().is_some() {
23263+ session.add_message(Message {
23264+ message: "You are already logged in.".into(),
23265+ level: Level::Info,
23266+ })?;
23267+ return Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())));
23268+ }
23269+
23270+ let now: i64 = chrono::offset::Utc::now().timestamp();
23271+
23272+ let (_prev_token, _) = if let Some(tok @ (_, timestamp)) =
23273+ session.get::<(String, i64)>(TOKEN_KEY)
23274+ {
23275+ if !(timestamp <= now && now - timestamp < EXPIRY_IN_SECS) {
23276+ session.add_message(Message {
23277+ message: "The token has expired. Please retry.".into(),
23278+ level: Level::Error,
23279+ })?;
23280+ return Ok(Redirect::to(&format!(
23281+ "{}{}?next={}",
23282+ state.root_url_prefix,
23283+ LoginPath.to_uri(),
23284+ next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
23285+ "?next={}",
23286+ percent_encoding::utf8_percent_encode(
23287+ next.as_str(),
23288+ percent_encoding::CONTROLS
23289+ )
23290+ )
23291+ .into())
23292+ )));
23293+ } else {
23294+ tok
23295+ }
23296+ } else {
23297+ session.add_message(Message {
23298+ message: "The token has expired. Please retry.".into(),
23299+ level: Level::Error,
23300+ })?;
23301+ return Ok(Redirect::to(&format!(
23302+ "{}{}{}",
23303+ state.root_url_prefix,
23304+ LoginPath.to_uri(),
23305+ next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
23306+ "?next={}",
23307+ percent_encoding::utf8_percent_encode(next.as_str(), percent_encoding::CONTROLS)
23308+ )
23309+ .into())
23310+ )));
23311+ };
23312+
23313+ let db = Connection::open_db(state.conf.clone())?;
23314+ let mut acc = match db
23315+ .account_by_address(&payload.address)
23316+ .with_status(StatusCode::BAD_REQUEST)?
23317+ {
23318+ Some(v) => v,
23319+ None => {
23320+ session.add_message(Message {
23321+ message: "Invalid account details, please retry.".into(),
23322+ level: Level::Error,
23323+ })?;
23324+ return Ok(Redirect::to(&format!(
23325+ "{}{}{}",
23326+ state.root_url_prefix,
23327+ LoginPath.to_uri(),
23328+ next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
23329+ "?next={}",
23330+ percent_encoding::utf8_percent_encode(
23331+ next.as_str(),
23332+ percent_encoding::CONTROLS
23333+ )
23334+ )
23335+ .into())
23336+ )));
23337+ }
23338+ };
23339+ #[cfg(not(debug_assertions))]
23340+ let sig = SshSignature {
23341+ email: payload.address.clone(),
23342+ ssh_public_key: acc.password.clone(),
23343+ ssh_signature: payload.password.clone(),
23344+ namespace: std::env::var("SSH_NAMESPACE")
23345+ .unwrap_or_else(|_| "lists.mailpot.rs".to_string())
23346+ .into(),
23347+ token: _prev_token,
23348+ };
23349+ #[cfg(not(debug_assertions))]
23350+ {
23351+ #[cfg(not(feature = "ssh-key"))]
23352+ let ssh_verify_fn = ssh_verify;
23353+ #[cfg(feature = "ssh-key")]
23354+ let ssh_verify_fn = ssh_verify_in_memory;
23355+ if let Err(err) = ssh_verify_fn(sig).await {
23356+ session.add_message(Message {
23357+ message: format!("Could not verify signature: {err}").into(),
23358+ level: Level::Error,
23359+ })?;
23360+ return Ok(Redirect::to(&format!(
23361+ "{}{}{}",
23362+ state.root_url_prefix,
23363+ LoginPath.to_uri(),
23364+ next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
23365+ "?next={}",
23366+ percent_encoding::utf8_percent_encode(
23367+ next.as_str(),
23368+ percent_encoding::CONTROLS
23369+ )
23370+ )
23371+ .into())
23372+ )));
23373+ }
23374+ }
23375+
23376+ let user = User {
23377+ pk: acc.pk(),
23378+ ssh_signature: payload.password,
23379+ role: if db
23380+ .conf()
23381+ .administrators
23382+ .iter()
23383+ .any(|a| a.eq_ignore_ascii_case(&payload.address))
23384+ {
23385+ Role::Admin
23386+ } else {
23387+ Role::User
23388+ },
23389+ public_key: std::mem::take(&mut acc.public_key),
23390+ password: std::mem::take(&mut acc.password),
23391+ name: std::mem::take(&mut acc.name),
23392+ address: payload.address,
23393+ enabled: acc.enabled,
23394+ };
23395+ state.insert_user(acc.pk(), user.clone()).await;
23396+ drop(session);
23397+ auth.login(&user)
23398+ .await
23399+ .map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?;
23400+ Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())))
23401+ }
23402+
23403+ #[derive(Debug, Clone, Default)]
23404+ pub struct SshSignature {
23405+ pub email: String,
23406+ pub ssh_public_key: String,
23407+ pub ssh_signature: String,
23408+ pub namespace: Cow<'static, str>,
23409+ pub token: String,
23410+ }
23411+
23412+ /// Run ssh signature validation with `ssh-keygen` binary.
23413+ ///
23414+ /// ```no_run
23415+ /// use mailpot_web::{ssh_verify, SshSignature};
23416+ ///
23417+ /// async fn verify_signature(
23418+ /// ssh_public_key: String,
23419+ /// ssh_signature: String,
23420+ /// ) -> std::result::Result<(), Box<dyn std::error::Error>> {
23421+ /// let sig = SshSignature {
23422+ /// email: "user@example.com".to_string(),
23423+ /// ssh_public_key,
23424+ /// ssh_signature,
23425+ /// namespace: "doc-test@example.com".into(),
23426+ /// token: "d074a61990".to_string(),
23427+ /// };
23428+ ///
23429+ /// ssh_verify(sig).await?;
23430+ /// Ok(())
23431+ /// }
23432+ /// ```
23433+ pub async fn ssh_verify(sig: SshSignature) -> Result<(), Box<dyn std::error::Error>> {
23434+ let SshSignature {
23435+ email,
23436+ ssh_public_key,
23437+ ssh_signature,
23438+ namespace,
23439+ token,
23440+ } = sig;
23441+ let dir = tempfile::tempdir()?;
23442+
23443+ let mut allowed_signers_fp = NamedTempFile::new_in(dir.path())?;
23444+ let mut signature_fp = NamedTempFile::new_in(dir.path())?;
23445+ {
23446+ let (tempfile, path) = allowed_signers_fp.into_parts();
23447+ let mut file = File::from(tempfile);
23448+
23449+ file.write_all(format!("{email} {ssh_public_key}").as_bytes())
23450+ .await?;
23451+ file.flush().await?;
23452+ allowed_signers_fp = NamedTempFile::from_parts(file.into_std().await, path);
23453+ }
23454+ {
23455+ let (tempfile, path) = signature_fp.into_parts();
23456+ let mut file = File::from(tempfile);
23457+
23458+ file.write_all(ssh_signature.trim().replace("\r\n", "\n").as_bytes())
23459+ .await?;
23460+ file.flush().await?;
23461+ signature_fp = NamedTempFile::from_parts(file.into_std().await, path);
23462+ }
23463+
23464+ let mut cmd = Command::new("ssh-keygen");
23465+
23466+ cmd.stdout(Stdio::piped());
23467+ cmd.stderr(Stdio::piped());
23468+ cmd.stdin(Stdio::piped());
23469+
23470+ // Once you have your allowed signers file, verification works like this:
23471+ //
23472+ // ```shell
23473+ // ssh-keygen -Y verify -f allowed_signers -I alice@example.com -n file -s file_to_verify.sig < file_to_verify
23474+ // ```
23475+ //
23476+ // Here are the arguments you may need to change:
23477+ //
23478+ // - `allowed_signers` is the path to the allowed signers file.
23479+ // - `alice@example.com` is the email address of the person who allegedly signed
23480+ // the file. This email address is looked up in the allowed signers file to
23481+ // get possible public keys.
23482+ // - `file` is the "namespace", which must match the namespace used for signing
23483+ // as described above.
23484+ // - `file_to_verify.sig` is the path to the signature file.
23485+ // - `file_to_verify` is the path to the file to be verified. Note that this
23486+ // file is read from standard in. In the above command, the < shell operator
23487+ // is used to redirect standard in from this file.
23488+ //
23489+ // If the signature is valid, the command exits with status `0` and prints a
23490+ // message like this:
23491+ //
23492+ // > Good "file" signature for alice@example.com with ED25519 key
23493+ // > SHA256:ZGa8RztddW4kE2XKPPsP9ZYC7JnMObs6yZzyxg8xZSk
23494+ //
23495+ // Otherwise, the command exits with a non-zero status and prints an error
23496+ // message.
23497+
23498+ let mut child = cmd
23499+ .arg("-Y")
23500+ .arg("verify")
23501+ .arg("-f")
23502+ .arg(allowed_signers_fp.path())
23503+ .arg("-I")
23504+ .arg(&email)
23505+ .arg("-n")
23506+ .arg(namespace.as_ref())
23507+ .arg("-s")
23508+ .arg(signature_fp.path())
23509+ .spawn()
23510+ .expect("failed to spawn command");
23511+
23512+ let mut stdin = child
23513+ .stdin
23514+ .take()
23515+ .expect("child did not have a handle to stdin");
23516+
23517+ stdin
23518+ .write_all(token.as_bytes())
23519+ .await
23520+ .expect("could not write to stdin");
23521+
23522+ drop(stdin);
23523+
23524+ let op = child.wait_with_output().await?;
23525+
23526+ if !op.status.success() {
23527+ return Err(format!(
23528+ "ssh-keygen exited with {}:\nstdout: {}\n\nstderr: {}",
23529+ op.status.code().unwrap_or(-1),
23530+ String::from_utf8_lossy(&op.stdout),
23531+ String::from_utf8_lossy(&op.stderr)
23532+ )
23533+ .into());
23534+ }
23535+
23536+ Ok(())
23537+ }
23538+
23539+ /// Run ssh signature validation.
23540+ ///
23541+ /// ```no_run
23542+ /// use mailpot_web::{ssh_verify_in_memory, SshSignature};
23543+ ///
23544+ /// async fn ssh_verify(
23545+ /// ssh_public_key: String,
23546+ /// ssh_signature: String,
23547+ /// ) -> std::result::Result<(), Box<dyn std::error::Error>> {
23548+ /// let sig = SshSignature {
23549+ /// email: "user@example.com".to_string(),
23550+ /// ssh_public_key,
23551+ /// ssh_signature,
23552+ /// namespace: "doc-test@example.com".into(),
23553+ /// token: "d074a61990".to_string(),
23554+ /// };
23555+ ///
23556+ /// ssh_verify_in_memory(sig).await?;
23557+ /// Ok(())
23558+ /// }
23559+ /// ```
23560+ #[cfg(feature = "ssh-key")]
23561+ pub async fn ssh_verify_in_memory(sig: SshSignature) -> Result<(), Box<dyn std::error::Error>> {
23562+ use ssh_key::{PublicKey, SshSig};
23563+
23564+ let SshSignature {
23565+ email: _,
23566+ ref ssh_public_key,
23567+ ref ssh_signature,
23568+ ref namespace,
23569+ ref token,
23570+ } = sig;
23571+
23572+ let public_key = ssh_public_key.parse::<PublicKey>().map_err(|err| {
23573+ format!("Could not parse user's SSH public key. Is it valid? Reason given: {err}")
23574+ })?;
23575+ let signature = if ssh_signature.contains("\r\n") {
23576+ ssh_signature.trim().replace("\r\n", "\n").parse::<SshSig>()
23577+ } else {
23578+ ssh_signature.parse::<SshSig>()
23579+ }
23580+ .map_err(|err| format!("Invalid SSH signature. Reason given: {err}"))?;
23581+
23582+ if let Err(err) = public_key.verify(namespace, token.as_bytes(), &signature) {
23583+ use ssh_key::Error;
23584+
23585+ #[allow(clippy::wildcard_in_or_patterns)]
23586+ return match err {
23587+ Error::Io(err_kind) => {
23588+ log::error!(
23589+ "ssh signature could not be verified because of internal error:\nSignature \
23590+ was {sig:#?}\nError was {err_kind}."
23591+ );
23592+ Err("SSH signature could not be verified because of internal error.".into())
23593+ }
23594+ Error::Crypto => Err("SSH signature is invalid.".into()),
23595+ Error::AlgorithmUnknown
23596+ | Error::AlgorithmUnsupported { .. }
23597+ | Error::CertificateFieldInvalid(_)
23598+ | Error::CertificateValidation
23599+ | Error::Decrypted
23600+ | Error::Ecdsa(_)
23601+ | Error::Encoding(_)
23602+ | Error::Encrypted
23603+ | Error::FormatEncoding
23604+ | Error::Namespace
23605+ | Error::PublicKey
23606+ | Error::Time
23607+ | Error::TrailingData { .. }
23608+ | Error::Version { .. }
23609+ | _ => Err(format!("SSH signature could not be verified: Reason given: {err}").into()),
23610+ };
23611+ }
23612+
23613+ Ok(())
23614+ }
23615+
23616+ pub async fn logout_handler(
23617+ _: LogoutPath,
23618+ mut auth: AuthContext,
23619+ State(state): State<Arc<AppState>>,
23620+ ) -> Redirect {
23621+ auth.logout().await;
23622+ Redirect::to(&format!("{}/", state.root_url_prefix))
23623+ }
23624+
23625+ pub mod auth_request {
23626+ use std::{marker::PhantomData, ops::RangeBounds};
23627+
23628+ use axum::body::HttpBody;
23629+ use dyn_clone::DynClone;
23630+ use tower_http::auth::AuthorizeRequest;
23631+
23632+ use super::*;
23633+
23634+ trait RoleBounds<Role>: DynClone + Send + Sync {
23635+ fn contains(&self, role: Option<Role>) -> bool;
23636+ }
23637+
23638+ impl<T, Role> RoleBounds<Role> for T
23639+ where
23640+ Role: PartialOrd + PartialEq,
23641+ T: RangeBounds<Role> + Clone + Send + Sync,
23642+ {
23643+ fn contains(&self, role: Option<Role>) -> bool {
23644+ role.as_ref()
23645+ .map_or_else(|| role.is_none(), |role| RangeBounds::contains(self, role))
23646+ }
23647+ }
23648+
23649+ /// Type that performs login authorization.
23650+ ///
23651+ /// See [`RequireAuthorizationLayer::login`] for more details.
23652+ pub struct Login<UserId, User, ResBody, Role = ()> {
23653+ login_url: Option<Arc<Cow<'static, str>>>,
23654+ redirect_field_name: Option<Arc<Cow<'static, str>>>,
23655+ role_bounds: Box<dyn RoleBounds<Role>>,
23656+ _user_id_type: PhantomData<UserId>,
23657+ _user_type: PhantomData<User>,
23658+ _body_type: PhantomData<fn() -> ResBody>,
23659+ }
23660+
23661+ impl<UserId, User, ResBody, Role> Clone for Login<UserId, User, ResBody, Role> {
23662+ fn clone(&self) -> Self {
23663+ Self {
23664+ login_url: self.login_url.clone(),
23665+ redirect_field_name: self.redirect_field_name.clone(),
23666+ role_bounds: dyn_clone::clone_box(&*self.role_bounds),
23667+ _user_id_type: PhantomData,
23668+ _user_type: PhantomData,
23669+ _body_type: PhantomData,
23670+ }
23671+ }
23672+ }
23673+
23674+ impl<UserId, User, ReqBody, ResBody, Role> AuthorizeRequest<ReqBody>
23675+ for Login<UserId, User, ResBody, Role>
23676+ where
23677+ Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
23678+ User: AuthUser<UserId, Role>,
23679+ ResBody: HttpBody + Default,
23680+ {
23681+ type ResponseBody = ResBody;
23682+
23683+ fn authorize(
23684+ &mut self,
23685+ request: &mut Request<ReqBody>,
23686+ ) -> Result<(), Response<Self::ResponseBody>> {
23687+ let user = request
23688+ .extensions()
23689+ .get::<Option<User>>()
23690+ .expect("Auth extension missing. Is the auth layer installed?");
23691+
23692+ match user {
23693+ Some(user) if self.role_bounds.contains(user.get_role()) => {
23694+ let user = user.clone();
23695+ request.extensions_mut().insert(user);
23696+
23697+ Ok(())
23698+ }
23699+
23700+ _ => {
23701+ let unauthorized_response = if let Some(ref login_url) = self.login_url {
23702+ let url: Cow<'static, str> = self.redirect_field_name.as_ref().map_or_else(
23703+ || login_url.as_ref().clone(),
23704+ |next| {
23705+ format!(
23706+ "{login_url}?{next}={}",
23707+ percent_encoding::utf8_percent_encode(
23708+ request.uri().path(),
23709+ percent_encoding::CONTROLS
23710+ )
23711+ )
23712+ .into()
23713+ },
23714+ );
23715+
23716+ Response::builder()
23717+ .status(http::StatusCode::TEMPORARY_REDIRECT)
23718+ .header(http::header::LOCATION, url.as_ref())
23719+ .body(Default::default())
23720+ .unwrap()
23721+ } else {
23722+ Response::builder()
23723+ .status(http::StatusCode::UNAUTHORIZED)
23724+ .body(Default::default())
23725+ .unwrap()
23726+ };
23727+
23728+ Err(unauthorized_response)
23729+ }
23730+ }
23731+ }
23732+ }
23733+
23734+ /// A wrapper around [`tower_http::auth::RequireAuthorizationLayer`] which
23735+ /// provides login authorization.
23736+ pub struct RequireAuthorizationLayer<UserId, User, Role = ()>(UserId, User, Role);
23737+
23738+ impl<UserId, User, Role> RequireAuthorizationLayer<UserId, User, Role>
23739+ where
23740+ Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
23741+ User: AuthUser<UserId, Role>,
23742+ {
23743+ /// Authorizes requests by requiring a logged in user, otherwise it
23744+ /// rejects with [`http::StatusCode::UNAUTHORIZED`].
23745+ pub fn login<ResBody>(
23746+ ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
23747+ where
23748+ ResBody: HttpBody + Default,
23749+ {
23750+ tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
23751+ login_url: None,
23752+ redirect_field_name: None,
23753+ role_bounds: Box::new(..),
23754+ _user_id_type: PhantomData,
23755+ _user_type: PhantomData,
23756+ _body_type: PhantomData,
23757+ })
23758+ }
23759+
23760+ /// Authorizes requests by requiring a logged in user to have a specific
23761+ /// range of roles, otherwise it rejects with
23762+ /// [`http::StatusCode::UNAUTHORIZED`].
23763+ pub fn login_with_role<ResBody>(
23764+ role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
23765+ ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
23766+ where
23767+ ResBody: HttpBody + Default,
23768+ {
23769+ tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
23770+ login_url: None,
23771+ redirect_field_name: None,
23772+ role_bounds: Box::new(role_bounds),
23773+ _user_id_type: PhantomData,
23774+ _user_type: PhantomData,
23775+ _body_type: PhantomData,
23776+ })
23777+ }
23778+
23779+ /// Authorizes requests by requiring a logged in user, otherwise it
23780+ /// redirects to the provided login URL.
23781+ ///
23782+ /// If `redirect_field_name` is set to a value, the login page will
23783+ /// receive the path it was redirected from in the URI query
23784+ /// part. For example, attempting to visit a protected path
23785+ /// `/protected` would redirect you to `/login?next=/protected` allowing
23786+ /// you to know how to return the visitor to their requested
23787+ /// page.
23788+ pub fn login_or_redirect<ResBody>(
23789+ login_url: Arc<Cow<'static, str>>,
23790+ redirect_field_name: Option<Arc<Cow<'static, str>>>,
23791+ ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
23792+ where
23793+ ResBody: HttpBody + Default,
23794+ {
23795+ tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
23796+ login_url: Some(login_url),
23797+ redirect_field_name,
23798+ role_bounds: Box::new(..),
23799+ _user_id_type: PhantomData,
23800+ _user_type: PhantomData,
23801+ _body_type: PhantomData,
23802+ })
23803+ }
23804+
23805+ /// Authorizes requests by requiring a logged in user to have a specific
23806+ /// range of roles, otherwise it redirects to the
23807+ /// provided login URL.
23808+ ///
23809+ /// If `redirect_field_name` is set to a value, the login page will
23810+ /// receive the path it was redirected from in the URI query
23811+ /// part. For example, attempting to visit a protected path
23812+ /// `/protected` would redirect you to `/login?next=/protected` allowing
23813+ /// you to know how to return the visitor to their requested
23814+ /// page.
23815+ pub fn login_with_role_or_redirect<ResBody>(
23816+ role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
23817+ login_url: Arc<Cow<'static, str>>,
23818+ redirect_field_name: Option<Arc<Cow<'static, str>>>,
23819+ ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
23820+ where
23821+ ResBody: HttpBody + Default,
23822+ {
23823+ tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
23824+ login_url: Some(login_url),
23825+ redirect_field_name,
23826+ role_bounds: Box::new(role_bounds),
23827+ _user_id_type: PhantomData,
23828+ _user_type: PhantomData,
23829+ _body_type: PhantomData,
23830+ })
23831+ }
23832+ }
23833+ }
23834+
23835+ #[cfg(test)]
23836+ mod tests {
23837+ use super::*;
23838+ const PKEY: &str = concat!(
23839+ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCzXp8nLJL8GPNw7S+Dqt0m3Dw/",
23840+ "xFOAdwKXcekTFI9cLDEUII2rNPf0uUZTpv57OgU+",
23841+ "QOEEIvWMjz+5KSWBX8qdP8OtV0QNvynlZkEKZN0cUqGKaNXo5a+PUDyiJ2rHroPe1aMo6mUBL9kLR6J2U1CYD/dLfL8ywXsAGmOL0bsK0GRPVBJAjpUNRjpGU/",
23842+ "2FFIlU6s6GawdbDXEHDox/UoOVAKIlhKabaTrFBA0ACFLRX2/GCBmHqqt5d4ZZjefYzReLs/beOjafYImoyhHC428wZDcUjvLrpSJbIOE/",
23843+ "gSPCWlRbcsxg4JGcKOtALUurE+ok+avy9M7eFjGhLGSlTKLdshIVQr/3W667M7bYfOT6xP/",
23844+ "lyjxeWIUYyj7rjlqKJ9tzygek7QNxCtuqH5xsZAZqzQCN8wfrPAlwDykvWityKOw+Bt2DWjimITqyKgsBsOaA+",
23845+ "eVCllFvooJxoYvAjODASjAUoOdgVzyBDpFnOhLFYiIIyL3F6NROS9i7z086paX7mrzcQzvLr4ckF9qT7DrI88ikISCR9bFR4vPq3aH",
23846+ "zJdjDDpWxACa5b11NG8KdCJPe/L0kDw82Q00U13CpW9FI9sZjvk+",
23847+ "lyw8bTFvVsIl6A0ueboFvrNvznAqHrtfWu75fXRh5sKj2TGk8rhm3vyNgrBSr5zAfFVM8LgqBxbAAYw=="
23848+ );
23849+
23850+ const ARMOR_SIG: &str = concat!(
23851+ "-----BEGIN SSH SIGNATURE-----\n",
23852+ "U1NIU0lHAAAAAQAAAhcAAAAHc3NoLXJzYQAAAAMBAAEAAAIBALNenycskvwY83DtL4Oq3S\n",
23853+ "bcPD/EU4B3Apdx6RMUj1wsMRQgjas09/S5RlOm/ns6BT5A4QQi9YyPP7kpJYFfyp0/w61X\n",
23854+ "RA2/KeVmQQpk3RxSoYpo1ejlr49QPKInaseug97VoyjqZQEv2QtHonZTUJgP90t8vzLBew\n",
23855+ "AaY4vRuwrQZE9UEkCOlQ1GOkZT/YUUiVTqzoZrB1sNcQcOjH9Sg5UAoiWEpptpOsUEDQAI\n",
23856+ "UtFfb8YIGYeqq3l3hlmN59jNF4uz9t46Np9giajKEcLjbzBkNxSO8uulIlsg4T+BI8JaVF\n",
23857+ "tyzGDgkZwo60AtS6sT6iT5q/L0zt4WMaEsZKVMot2yEhVCv/dbrrsztth85PrE/+XKPF5Y\n",
23858+ "hRjKPuuOWoon23PKB6TtA3EK26ofnGxkBmrNAI3zB+s8CXAPKS9aK3Io7D4G3YNaOKYhOr\n",
23859+ "IqCwGw5oD55UKWUW+ignGhi8CM4MBKMBSg52BXPIEOkWc6EsViIgjIvcXo1E5L2LvPTzql\n",
23860+ "pfuavNxDO8uvhyQX2pPsOsjzyKQhIJH1sVHi8+rdofMl2MMOlbEAJrlvXU0bwp0Ik978vS\n",
23861+ "QPDzZDTRTXcKlb0Uj2xmO+T6XLDxtMW9WwiXoDS55ugW+s2/OcCoeu19a7vl9dGHmwqPZM\n",
23862+ "aTyuGbe/I2CsFKvnMB8VUzwuCoHFsABjAAAAFGRvYy10ZXN0QGV4YW1wbGUuY29tAAAAAA\n",
23863+ "AAAAZzaGE1MTIAAAIUAAAADHJzYS1zaGEyLTUxMgAAAgBxaMqIfeapKTrhQzggDssD+76s\n",
23864+ "jZxv3XxzgsuAjlIdtw+/nyxU6skTnrGoam2shpmQvx0HuqSQ7HyS2USBK7T4LZNoE53zR/\n",
23865+ "ZmHLGoyQAoexiHSEW9Lk53kyRNPhpXQedTvm8REHPGM3zw6WO6mAXVVxvebvawf81LTbBb\n",
23866+ "p9ubNRcHgktVeywMO/sD6zWSyShq1gjVv1PdRBOjUgqkwjImL8dFKi1QUeoffCxyk3JhTO\n",
23867+ "siTy79HZSz/kOvkvL1vQuqaP2R8lE9P1uaD19dGOMTPRod3u+QmpYX47ri5KM3Fmkfxdwq\n",
23868+ "p8JVmfAA9nme7bmNS1hWgmF2Nbh9qjh1zOZvCimIpuNtz5eEl9K+1DxG6w5tX86wSGvBMO\n",
23869+ "znx0k1gGfkiAULqgrkdul7mqMPRvPN9J6QlNJ7SLFChRhzlJIJc6tOvCs7qkVD43Zcb+I5\n",
23870+ "Z+K4NiFf5jf8kVX/pjjeW/ucbrctJIkGsZ58OkHKi1EDRcq7NtCF6SKlcv8g3fMLd9wW6K\n",
23871+ "aaed0TBDC+s+f6naNIGvWqfWCwDuK5xGyDTTmJGcrsMwWuT9K6uLk8cGdv7t5mOFuWi5jl\n",
23872+ "E+IKZKVABMuWqSj96ErMIiBjtsAZfNSezpsK49wQztoSPhdwLhD6fHrSAyPCqN2xRkcsIb\n",
23873+ "6PxWKC/OELf3gyEBRPouxsF7xSZQ==\n",
23874+ "-----END SSH SIGNATURE-----\n"
23875+ );
23876+
23877+ fn create_sig() -> SshSignature {
23878+ SshSignature {
23879+ email: "user@example.com".to_string(),
23880+ ssh_public_key: PKEY.to_string(),
23881+ ssh_signature: ARMOR_SIG.to_string(),
23882+ namespace: "doc-test@example.com".into(),
23883+ token: "d074a61990".to_string(),
23884+ }
23885+ }
23886+
23887+ #[tokio::test]
23888+ async fn test_ssh_verify() {
23889+ let mut sig = create_sig();
23890+ ssh_verify(sig.clone()).await.unwrap();
23891+
23892+ sig.ssh_signature = sig.ssh_signature.replace('J', "0");
23893+
23894+ let err = ssh_verify(sig).await.unwrap_err();
23895+
23896+ assert!(
23897+ err.to_string().starts_with("ssh-keygen exited with"),
23898+ "{}",
23899+ err
23900+ );
23901+ }
23902+
23903+ #[cfg(feature = "ssh-key")]
23904+ #[tokio::test]
23905+ async fn test_ssh_verify_in_memory() {
23906+ let mut sig = create_sig();
23907+ ssh_verify_in_memory(sig.clone()).await.unwrap();
23908+
23909+ sig.ssh_signature = sig.ssh_signature.replace('J', "0");
23910+
23911+ let err = ssh_verify_in_memory(sig.clone()).await.unwrap_err();
23912+
23913+ assert_eq!(
23914+ &err.to_string(),
23915+ "Invalid SSH signature. Reason given: invalid label: 'ssh-}3a'",
23916+ "{}",
23917+ err
23918+ );
23919+
23920+ sig.ssh_public_key = sig.ssh_public_key.replace(' ', "0");
23921+
23922+ let err = ssh_verify_in_memory(sig).await.unwrap_err();
23923+ assert_eq!(
23924+ &err.to_string(),
23925+ "Could not parse user's SSH public key. Is it valid? Reason given: length invalid",
23926+ "{}",
23927+ err
23928+ );
23929+
23930+ let mut sig = create_sig();
23931+ sig.token = sig.token.replace('d', "0");
23932+
23933+ let err = ssh_verify_in_memory(sig).await.unwrap_err();
23934+ assert_eq!(&err.to_string(), "SSH signature is invalid.", "{}", err);
23935+ }
23936+ }
23937 diff --git a/mailpot-web/src/cal.rs b/mailpot-web/src/cal.rs
23938new file mode 100644
23939index 0000000..370ebc1
23940--- /dev/null
23941+++ b/mailpot-web/src/cal.rs
23942 @@ -0,0 +1,243 @@
23943+ // MIT License
23944+ //
23945+ // Copyright (c) 2021 sadnessOjisan
23946+ //
23947+ // Permission is hereby granted, free of charge, to any person obtaining a copy
23948+ // of this software and associated documentation files (the "Software"), to deal
23949+ // in the Software without restriction, including without limitation the rights
23950+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23951+ // copies of the Software, and to permit persons to whom the Software is
23952+ // furnished to do so, subject to the following conditions:
23953+ //
23954+ // The above copyright notice and this permission notice shall be included in
23955+ // all copies or substantial portions of the Software.
23956+ //
23957+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23958+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23959+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23960+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23961+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23962+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23963+ // SOFTWARE.
23964+
23965+ use chrono::*;
23966+
23967+ #[allow(dead_code)]
23968+ /// Generate a calendar view of the given date's month.
23969+ ///
23970+ /// Each vector element is an array of seven numbers representing weeks
23971+ /// (starting on Sundays), and each value is the numeric date.
23972+ /// A value of zero means a date that not exists in the current month.
23973+ ///
23974+ /// # Examples
23975+ /// ```
23976+ /// use chrono::*;
23977+ /// use mailpot_web::calendarize;
23978+ ///
23979+ /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
23980+ /// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
23981+ /// println!("{:?}", calendarize(date));
23982+ /// // [0, 0, 0, 0, 0, 1, 2],
23983+ /// // [3, 4, 5, 6, 7, 8, 9],
23984+ /// // [10, 11, 12, 13, 14, 15, 16],
23985+ /// // [17, 18, 19, 20, 21, 22, 23],
23986+ /// // [24, 25, 26, 27, 28, 29, 30],
23987+ /// // [31, 0, 0, 0, 0, 0, 0]
23988+ /// ```
23989+ pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
23990+ calendarize_with_offset(date, 0)
23991+ }
23992+
23993+ /// Generate a calendar view of the given date's month and offset.
23994+ ///
23995+ /// Each vector element is an array of seven numbers representing weeks
23996+ /// (starting on Sundays), and each value is the numeric date.
23997+ /// A value of zero means a date that not exists in the current month.
23998+ ///
23999+ /// Offset means the number of days from sunday.
24000+ /// For example, 1 means monday, 6 means saturday.
24001+ ///
24002+ /// # Examples
24003+ /// ```
24004+ /// use chrono::*;
24005+ /// use mailpot_web::calendarize_with_offset;
24006+ ///
24007+ /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
24008+ /// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
24009+ /// println!("{:?}", calendarize_with_offset(date, 1));
24010+ /// // [0, 0, 0, 0, 1, 2, 3],
24011+ /// // [4, 5, 6, 7, 8, 9, 10],
24012+ /// // [11, 12, 13, 14, 15, 16, 17],
24013+ /// // [18, 19, 20, 21, 22, 23, 24],
24014+ /// // [25, 26, 27, 28, 29, 30, 0],
24015+ /// ```
24016+ pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
24017+ let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
24018+ let year = date.year();
24019+ let month = date.month();
24020+ let num_days_from_sunday = NaiveDate::from_ymd_opt(year, month, 1)
24021+ .unwrap()
24022+ .weekday()
24023+ .num_days_from_sunday();
24024+ let mut first_date_day = if num_days_from_sunday < offset {
24025+ num_days_from_sunday + (7 - offset)
24026+ } else {
24027+ num_days_from_sunday - offset
24028+ };
24029+ let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
24030+ .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
24031+ .pred_opt()
24032+ .unwrap()
24033+ .day();
24034+
24035+ let mut date: u32 = 0;
24036+ while date < end_date {
24037+ let mut week: [u32; 7] = [0; 7];
24038+ for day in first_date_day..7 {
24039+ date += 1;
24040+ week[day as usize] = date;
24041+ if date >= end_date {
24042+ break;
24043+ }
24044+ }
24045+ first_date_day = 0;
24046+
24047+ monthly_calendar.push(week);
24048+ }
24049+
24050+ monthly_calendar
24051+ }
24052+
24053+ #[test]
24054+ fn january() {
24055+ let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
24056+ let actual = calendarize(date);
24057+ assert_eq!(
24058+ vec![
24059+ [0, 0, 0, 0, 0, 1, 2],
24060+ [3, 4, 5, 6, 7, 8, 9],
24061+ [10, 11, 12, 13, 14, 15, 16],
24062+ [17, 18, 19, 20, 21, 22, 23],
24063+ [24, 25, 26, 27, 28, 29, 30],
24064+ [31, 0, 0, 0, 0, 0, 0]
24065+ ],
24066+ actual
24067+ );
24068+ }
24069+
24070+ #[test]
24071+ // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
24072+ fn with_offset_from_sunday() {
24073+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
24074+ let actual = calendarize_with_offset(date, 0);
24075+ assert_eq!(
24076+ vec![
24077+ [0, 0, 0, 0, 0, 1, 2],
24078+ [3, 4, 5, 6, 7, 8, 9],
24079+ [10, 11, 12, 13, 14, 15, 16],
24080+ [17, 18, 19, 20, 21, 22, 23],
24081+ [24, 25, 26, 27, 28, 29, 30],
24082+ ],
24083+ actual
24084+ );
24085+ }
24086+
24087+ #[test]
24088+ // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
24089+ fn with_offset_from_monday() {
24090+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
24091+ let actual = calendarize_with_offset(date, 1);
24092+ assert_eq!(
24093+ vec![
24094+ [0, 0, 0, 0, 1, 2, 3],
24095+ [4, 5, 6, 7, 8, 9, 10],
24096+ [11, 12, 13, 14, 15, 16, 17],
24097+ [18, 19, 20, 21, 22, 23, 24],
24098+ [25, 26, 27, 28, 29, 30, 0],
24099+ ],
24100+ actual
24101+ );
24102+ }
24103+
24104+ #[test]
24105+ // Week = [Sat, Sun, Mon, Tue, Wed, Thu, Fri]
24106+ fn with_offset_from_saturday() {
24107+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
24108+ let actual = calendarize_with_offset(date, 6);
24109+ assert_eq!(
24110+ vec![
24111+ [0, 0, 0, 0, 0, 0, 1],
24112+ [2, 3, 4, 5, 6, 7, 8],
24113+ [9, 10, 11, 12, 13, 14, 15],
24114+ [16, 17, 18, 19, 20, 21, 22],
24115+ [23, 24, 25, 26, 27, 28, 29],
24116+ [30, 0, 0, 0, 0, 0, 0]
24117+ ],
24118+ actual
24119+ );
24120+ }
24121+
24122+ #[test]
24123+ // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
24124+ fn with_offset_from_sunday_with7() {
24125+ let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
24126+ let actual = calendarize_with_offset(date, 7);
24127+ assert_eq!(
24128+ vec![
24129+ [0, 0, 0, 0, 0, 1, 2],
24130+ [3, 4, 5, 6, 7, 8, 9],
24131+ [10, 11, 12, 13, 14, 15, 16],
24132+ [17, 18, 19, 20, 21, 22, 23],
24133+ [24, 25, 26, 27, 28, 29, 30],
24134+ ],
24135+ actual
24136+ );
24137+ }
24138+
24139+ #[test]
24140+ fn april() {
24141+ let date = NaiveDate::parse_from_str("2021-04-02", "%Y-%m-%d").unwrap();
24142+ let actual = calendarize(date);
24143+ assert_eq!(
24144+ vec![
24145+ [0, 0, 0, 0, 1, 2, 3],
24146+ [4, 5, 6, 7, 8, 9, 10],
24147+ [11, 12, 13, 14, 15, 16, 17],
24148+ [18, 19, 20, 21, 22, 23, 24],
24149+ [25, 26, 27, 28, 29, 30, 0]
24150+ ],
24151+ actual
24152+ );
24153+ }
24154+
24155+ #[test]
24156+ fn uruudoshi() {
24157+ let date = NaiveDate::parse_from_str("2020-02-02", "%Y-%m-%d").unwrap();
24158+ let actual = calendarize(date);
24159+ assert_eq!(
24160+ vec![
24161+ [0, 0, 0, 0, 0, 0, 1],
24162+ [2, 3, 4, 5, 6, 7, 8],
24163+ [9, 10, 11, 12, 13, 14, 15],
24164+ [16, 17, 18, 19, 20, 21, 22],
24165+ [23, 24, 25, 26, 27, 28, 29]
24166+ ],
24167+ actual
24168+ );
24169+ }
24170+
24171+ #[test]
24172+ fn uruwanaidoshi() {
24173+ let date = NaiveDate::parse_from_str("2021-02-02", "%Y-%m-%d").unwrap();
24174+ let actual = calendarize(date);
24175+ assert_eq!(
24176+ vec![
24177+ [0, 1, 2, 3, 4, 5, 6],
24178+ [7, 8, 9, 10, 11, 12, 13],
24179+ [14, 15, 16, 17, 18, 19, 20],
24180+ [21, 22, 23, 24, 25, 26, 27],
24181+ [28, 0, 0, 0, 0, 0, 0]
24182+ ],
24183+ actual
24184+ );
24185+ }
24186 diff --git a/mailpot-web/src/help.rs b/mailpot-web/src/help.rs
24187new file mode 100644
24188index 0000000..9a3c9c4
24189--- /dev/null
24190+++ b/mailpot-web/src/help.rs
24191 @@ -0,0 +1,45 @@
24192+ /*
24193+ * This file is part of mailpot
24194+ *
24195+ * Copyright 2020 - Manos Pitsidianakis
24196+ *
24197+ * This program is free software: you can redistribute it and/or modify
24198+ * it under the terms of the GNU Affero General Public License as
24199+ * published by the Free Software Foundation, either version 3 of the
24200+ * License, or (at your option) any later version.
24201+ *
24202+ * This program is distributed in the hope that it will be useful,
24203+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
24204+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24205+ * GNU Affero General Public License for more details.
24206+ *
24207+ * You should have received a copy of the GNU Affero General Public License
24208+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
24209+ */
24210+
24211+ use super::*;
24212+
24213+ /// Show help page.
24214+ pub async fn help(
24215+ _: HelpPath,
24216+ mut session: WritableSession,
24217+ auth: AuthContext,
24218+ ) -> Result<Html<String>, ResponseError> {
24219+ let crumbs = vec![
24220+ Crumb {
24221+ label: "Home".into(),
24222+ url: "/".into(),
24223+ },
24224+ Crumb {
24225+ label: "Help".into(),
24226+ url: HelpPath.to_crumb(),
24227+ },
24228+ ];
24229+ let context = minijinja::context! {
24230+ page_title => "Help & Documentation",
24231+ current_user => auth.current_user,
24232+ messages => session.drain_messages(),
24233+ crumbs => crumbs,
24234+ };
24235+ Ok(Html(TEMPLATES.get_template("help.html")?.render(context)?))
24236+ }
24237 diff --git a/mailpot-web/src/lib.rs b/mailpot-web/src/lib.rs
24238new file mode 100644
24239index 0000000..a7c35bd
24240--- /dev/null
24241+++ b/mailpot-web/src/lib.rs
24242 @@ -0,0 +1,233 @@
24243+ /*
24244+ * This file is part of mailpot
24245+ *
24246+ * Copyright 2020 - Manos Pitsidianakis
24247+ *
24248+ * This program is free software: you can redistribute it and/or modify
24249+ * it under the terms of the GNU Affero General Public License as
24250+ * published by the Free Software Foundation, either version 3 of the
24251+ * License, or (at your option) any later version.
24252+ *
24253+ * This program is distributed in the hope that it will be useful,
24254+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
24255+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24256+ * GNU Affero General Public License for more details.
24257+ *
24258+ * You should have received a copy of the GNU Affero General Public License
24259+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
24260+ */
24261+
24262+ #![deny(
24263+ //missing_docs,
24264+ rustdoc::broken_intra_doc_links,
24265+ /* groups */
24266+ clippy::correctness,
24267+ clippy::suspicious,
24268+ clippy::complexity,
24269+ clippy::perf,
24270+ clippy::style,
24271+ clippy::cargo,
24272+ clippy::nursery,
24273+ /* restriction */
24274+ clippy::dbg_macro,
24275+ clippy::rc_buffer,
24276+ clippy::as_underscore,
24277+ clippy::assertions_on_result_states,
24278+ /* pedantic */
24279+ clippy::cast_lossless,
24280+ clippy::cast_possible_wrap,
24281+ clippy::ptr_as_ptr,
24282+ clippy::bool_to_int_with_if,
24283+ clippy::borrow_as_ptr,
24284+ clippy::case_sensitive_file_extension_comparisons,
24285+ clippy::cast_lossless,
24286+ clippy::cast_ptr_alignment,
24287+ clippy::naive_bytecount
24288+ )]
24289+ #![allow(clippy::multiple_crate_versions, clippy::missing_const_for_fn)]
24290+
24291+ pub use axum::{
24292+ extract::{Path, Query, State},
24293+ handler::Handler,
24294+ response::{Html, IntoResponse, Redirect},
24295+ routing::{get, post},
24296+ Extension, Form, Router,
24297+ };
24298+ pub use axum_extra::routing::TypedPath;
24299+ pub use axum_login::{
24300+ memory_store::MemoryStore as AuthMemoryStore, secrecy::SecretVec, AuthLayer, AuthUser,
24301+ RequireAuthorizationLayer,
24302+ };
24303+ pub use axum_sessions::{
24304+ async_session::MemoryStore,
24305+ extractors::{ReadableSession, WritableSession},
24306+ SessionLayer,
24307+ };
24308+
24309+ pub type AuthContext =
24310+ axum_login::extractors::AuthContext<i64, auth::User, Arc<AppState>, auth::Role>;
24311+
24312+ pub type RequireAuth = auth::auth_request::RequireAuthorizationLayer<i64, auth::User, auth::Role>;
24313+
24314+ pub use std::result::Result;
24315+ use std::{borrow::Cow, collections::HashMap, sync::Arc};
24316+
24317+ use chrono::Datelike;
24318+ pub use http::{Request, Response, StatusCode};
24319+ use mailpot::{models::DbVal, rusqlite::OptionalExtension, *};
24320+ use minijinja::{
24321+ value::{Object, Value},
24322+ Environment, Error,
24323+ };
24324+ use tokio::sync::RwLock;
24325+
24326+ pub mod auth;
24327+ pub mod cal;
24328+ pub mod help;
24329+ pub mod lists;
24330+ pub mod minijinja_utils;
24331+ pub mod settings;
24332+ pub mod topics;
24333+ pub mod typed_paths;
24334+ pub mod utils;
24335+
24336+ pub use auth::*;
24337+ pub use cal::{calendarize, *};
24338+ pub use help::*;
24339+ pub use lists::{
24340+ list, list_candidates, list_edit, list_edit_POST, list_post, list_post_eml, list_post_raw,
24341+ list_subscribers, PostPolicySettings, SubscriptionPolicySettings,
24342+ };
24343+ pub use minijinja_utils::*;
24344+ pub use settings::{
24345+ settings, settings_POST, user_list_subscription, user_list_subscription_POST,
24346+ SubscriptionFormPayload,
24347+ };
24348+ pub use topics::*;
24349+ pub use typed_paths::{tsr::RouterExt, *};
24350+ pub use utils::*;
24351+
24352+ #[derive(Debug)]
24353+ pub struct ResponseError {
24354+ pub inner: Box<dyn std::error::Error>,
24355+ pub status: StatusCode,
24356+ }
24357+
24358+ impl std::fmt::Display for ResponseError {
24359+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
24360+ write!(fmt, "Inner: {}, status: {}", self.inner, self.status)
24361+ }
24362+ }
24363+
24364+ impl ResponseError {
24365+ pub fn new(msg: String, status: StatusCode) -> Self {
24366+ Self {
24367+ inner: Box::<dyn std::error::Error + Send + Sync>::from(msg),
24368+ status,
24369+ }
24370+ }
24371+ }
24372+
24373+ impl<E: Into<Box<dyn std::error::Error>>> From<E> for ResponseError {
24374+ fn from(err: E) -> Self {
24375+ Self {
24376+ inner: err.into(),
24377+ status: StatusCode::INTERNAL_SERVER_ERROR,
24378+ }
24379+ }
24380+ }
24381+
24382+ pub trait IntoResponseError {
24383+ fn with_status(self, status: StatusCode) -> ResponseError;
24384+ }
24385+
24386+ impl<E: Into<Box<dyn std::error::Error>>> IntoResponseError for E {
24387+ fn with_status(self, status: StatusCode) -> ResponseError {
24388+ ResponseError {
24389+ status,
24390+ ..ResponseError::from(self)
24391+ }
24392+ }
24393+ }
24394+
24395+ impl IntoResponse for ResponseError {
24396+ fn into_response(self) -> axum::response::Response {
24397+ let Self { inner, status } = self;
24398+ (status, inner.to_string()).into_response()
24399+ }
24400+ }
24401+
24402+ pub trait IntoResponseErrorResult<R> {
24403+ fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError>;
24404+ }
24405+
24406+ impl<R, E> IntoResponseErrorResult<R> for std::result::Result<R, E>
24407+ where
24408+ E: IntoResponseError,
24409+ {
24410+ fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError> {
24411+ self.map_err(|err| err.with_status(status))
24412+ }
24413+ }
24414+
24415+ #[derive(Clone)]
24416+ pub struct AppState {
24417+ pub conf: Configuration,
24418+ pub root_url_prefix: Value,
24419+ pub public_url: String,
24420+ pub site_title: Cow<'static, str>,
24421+ pub site_subtitle: Option<Cow<'static, str>>,
24422+ pub user_store: Arc<RwLock<HashMap<i64, User>>>,
24423+ // ...
24424+ }
24425+
24426+ mod auth_impls {
24427+ use super::*;
24428+ type UserId = i64;
24429+ type User = auth::User;
24430+ type Role = auth::Role;
24431+
24432+ impl AppState {
24433+ pub async fn insert_user(&self, pk: UserId, user: User) {
24434+ self.user_store.write().await.insert(pk, user);
24435+ }
24436+ }
24437+
24438+ #[axum::async_trait]
24439+ impl axum_login::UserStore<UserId, Role> for Arc<AppState>
24440+ where
24441+ User: axum_login::AuthUser<UserId, Role>,
24442+ {
24443+ type User = User;
24444+
24445+ async fn load_user(
24446+ &self,
24447+ user_id: &UserId,
24448+ ) -> std::result::Result<Option<Self::User>, eyre::Report> {
24449+ Ok(self.user_store.read().await.get(user_id).cloned())
24450+ }
24451+ }
24452+ }
24453+
24454+ const fn _get_package_git_sha() -> Option<&'static str> {
24455+ option_env!("PACKAGE_GIT_SHA")
24456+ }
24457+
24458+ const _PACKAGE_COMMIT_SHA: Option<&str> = _get_package_git_sha();
24459+
24460+ pub fn get_git_sha() -> std::borrow::Cow<'static, str> {
24461+ if let Some(r) = _PACKAGE_COMMIT_SHA {
24462+ return r.into();
24463+ }
24464+ build_info::build_info!(fn build_info);
24465+ let info = build_info();
24466+ info.version_control
24467+ .as_ref()
24468+ .and_then(|v| v.git())
24469+ .map(|g| g.commit_short_id.clone())
24470+ .map_or_else(|| "<unknown>".into(), |v| v.into())
24471+ }
24472+
24473+ pub const VERSION_INFO: &str = build_info::format!("{}", $.crate_info.version);
24474+ pub const BUILD_INFO: &str = build_info::format!("{}\t{}\t{}\t{}", $.crate_info.version, $.compiler, $.timestamp, $.crate_info.enabled_features);
24475+ pub const CLI_INFO: &str = build_info::format!("{} Version: {}\nAuthors: {}\nLicense: AGPL version 3 or later\nCompiler: {}\nBuild-Date: {}\nEnabled-features: {}", $.crate_info.name, $.crate_info.version, $.crate_info.authors, $.compiler, $.timestamp, $.crate_info.enabled_features);
24476 diff --git a/mailpot-web/src/lists.rs b/mailpot-web/src/lists.rs
24477new file mode 100644
24478index 0000000..f9d130e
24479--- /dev/null
24480+++ b/mailpot-web/src/lists.rs
24481 @@ -0,0 +1,821 @@
24482+ /*
24483+ * This file is part of mailpot
24484+ *
24485+ * Copyright 2020 - Manos Pitsidianakis
24486+ *
24487+ * This program is free software: you can redistribute it and/or modify
24488+ * it under the terms of the GNU Affero General Public License as
24489+ * published by the Free Software Foundation, either version 3 of the
24490+ * License, or (at your option) any later version.
24491+ *
24492+ * This program is distributed in the hope that it will be useful,
24493+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
24494+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24495+ * GNU Affero General Public License for more details.
24496+ *
24497+ * You should have received a copy of the GNU Affero General Public License
24498+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
24499+ */
24500+
24501+ use chrono::TimeZone;
24502+ use indexmap::IndexMap;
24503+ use mailpot::{models::Post, StripCarets, StripCaretsInplace};
24504+
24505+ use super::*;
24506+
24507+ /// Mailing list index.
24508+ pub async fn list(
24509+ ListPath(id): ListPath,
24510+ mut session: WritableSession,
24511+ auth: AuthContext,
24512+ State(state): State<Arc<AppState>>,
24513+ ) -> Result<Html<String>, ResponseError> {
24514+ let db = Connection::open_db(state.conf.clone())?;
24515+ let Some(list) = (match id {
24516+ ListPathIdentifier::Pk(id) => db.list(id)?,
24517+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
24518+ }) else {
24519+ return Err(ResponseError::new(
24520+ "List not found".to_string(),
24521+ StatusCode::NOT_FOUND,
24522+ ));
24523+ };
24524+ let post_policy = db.list_post_policy(list.pk)?;
24525+ let subscription_policy = db.list_subscription_policy(list.pk)?;
24526+ let months = db.months(list.pk)?;
24527+ let user_context = auth
24528+ .current_user
24529+ .as_ref()
24530+ .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
24531+
24532+ let posts = db.list_posts(list.pk, None)?;
24533+ let post_map = posts
24534+ .iter()
24535+ .map(|p| (p.message_id.as_str(), p))
24536+ .collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>();
24537+ let mut hist = months
24538+ .iter()
24539+ .map(|m| (m.to_string(), [0usize; 31]))
24540+ .collect::<HashMap<String, [usize; 31]>>();
24541+ let envelopes: Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>> =
24542+ Default::default();
24543+ {
24544+ let mut env_lock = envelopes.write().unwrap();
24545+
24546+ for post in &posts {
24547+ let Ok(mut envelope) = melib::Envelope::from_bytes(post.message.as_slice(), None)
24548+ else {
24549+ continue;
24550+ };
24551+ if envelope.message_id != post.message_id.as_str() {
24552+ // If they don't match, the raw envelope doesn't contain a Message-ID and it was
24553+ // randomly generated. So set the envelope's Message-ID to match the
24554+ // post's, which is the "permanent" one since our source of truth is
24555+ // the database.
24556+ envelope.set_message_id(post.message_id.as_bytes());
24557+ }
24558+ env_lock.insert(envelope.hash(), envelope);
24559+ }
24560+ }
24561+ let mut threads: melib::Threads = melib::Threads::new(posts.len());
24562+ threads.amend(&envelopes);
24563+ let roots = thread_roots(&envelopes, &threads);
24564+ let posts_ctx = roots
24565+ .into_iter()
24566+ .filter_map(|(thread, length, _timestamp)| {
24567+ let post = &post_map[&thread.message_id.as_str()];
24568+ //2019-07-14T14:21:02
24569+ if let Some(day) =
24570+ chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc2822(post.datetime.trim())
24571+ .ok()
24572+ .map(|d| d.day())
24573+ {
24574+ hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
24575+ }
24576+ let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None).ok()?;
24577+ let mut msg_id = &post.message_id[1..];
24578+ msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
24579+ let subject = envelope.subject();
24580+ let mut subject_ref = subject.trim();
24581+ if subject_ref.starts_with('[')
24582+ && subject_ref[1..].starts_with(&list.id)
24583+ && subject_ref[1 + list.id.len()..].starts_with(']')
24584+ {
24585+ subject_ref = subject_ref[2 + list.id.len()..].trim();
24586+ }
24587+ let ret = minijinja::context! {
24588+ pk => post.pk,
24589+ list => post.list,
24590+ subject => subject_ref,
24591+ address => post.address,
24592+ message_id => msg_id,
24593+ message => post.message,
24594+ timestamp => post.timestamp,
24595+ datetime => post.datetime,
24596+ replies => length.saturating_sub(1),
24597+ last_active => thread.datetime,
24598+ };
24599+ Some(ret)
24600+ })
24601+ .collect::<Vec<_>>();
24602+ let crumbs = vec![
24603+ Crumb {
24604+ label: "Home".into(),
24605+ url: "/".into(),
24606+ },
24607+ Crumb {
24608+ label: list.name.clone().into(),
24609+ url: ListPath(list.id.to_string().into()).to_crumb(),
24610+ },
24611+ ];
24612+ let list_owners = db.list_owners(list.pk)?;
24613+ let mut list_obj = MailingList::from(list.clone());
24614+ list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
24615+ let context = minijinja::context! {
24616+ canonical_url => ListPath::from(&list).to_crumb(),
24617+ page_title => &list.name,
24618+ description => &list.description,
24619+ post_policy,
24620+ subscription_policy,
24621+ preamble => true,
24622+ months,
24623+ hists => &hist,
24624+ posts => posts_ctx,
24625+ list => Value::from_object(list_obj),
24626+ current_user => auth.current_user,
24627+ user_context,
24628+ messages => session.drain_messages(),
24629+ crumbs,
24630+ };
24631+ Ok(Html(
24632+ TEMPLATES.get_template("lists/list.html")?.render(context)?,
24633+ ))
24634+ }
24635+
24636+ /// Mailing list post page.
24637+ pub async fn list_post(
24638+ ListPostPath(id, msg_id): ListPostPath,
24639+ mut session: WritableSession,
24640+ auth: AuthContext,
24641+ State(state): State<Arc<AppState>>,
24642+ ) -> Result<Html<String>, ResponseError> {
24643+ let db = Connection::open_db(state.conf.clone())?.trusted();
24644+ let Some(list) = (match id {
24645+ ListPathIdentifier::Pk(id) => db.list(id)?,
24646+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
24647+ }) else {
24648+ return Err(ResponseError::new(
24649+ "List not found".to_string(),
24650+ StatusCode::NOT_FOUND,
24651+ ));
24652+ };
24653+ let user_context = auth.current_user.as_ref().map(|user| {
24654+ db.list_subscription_by_address(list.pk(), &user.address)
24655+ .ok()
24656+ });
24657+
24658+ let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
24659+ post
24660+ } else {
24661+ return Err(ResponseError::new(
24662+ format!("Post with Message-ID {} not found", msg_id),
24663+ StatusCode::NOT_FOUND,
24664+ ));
24665+ };
24666+ let thread: Vec<(i64, DbVal<Post>, String, String)> = {
24667+ let thread: Vec<(i64, DbVal<Post>)> = db.list_thread(list.pk, &post.message_id)?;
24668+
24669+ thread
24670+ .into_iter()
24671+ .map(|(depth, p)| {
24672+ let envelope = melib::Envelope::from_bytes(p.message.as_slice(), None).unwrap();
24673+ let body = envelope.body_bytes(p.message.as_slice());
24674+ let body_text = body.text();
24675+ let date = envelope.date_as_str().to_string();
24676+ (depth, p, body_text, date)
24677+ })
24678+ .collect()
24679+ };
24680+ let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
24681+ .with_status(StatusCode::BAD_REQUEST)?;
24682+ let body = envelope.body_bytes(post.message.as_slice());
24683+ let body_text = body.text();
24684+ let subject = envelope.subject();
24685+ let mut subject_ref = subject.trim();
24686+ if subject_ref.starts_with('[')
24687+ && subject_ref[1..].starts_with(&list.id)
24688+ && subject_ref[1 + list.id.len()..].starts_with(']')
24689+ {
24690+ subject_ref = subject_ref[2 + list.id.len()..].trim();
24691+ }
24692+ let crumbs = vec![
24693+ Crumb {
24694+ label: "Home".into(),
24695+ url: "/".into(),
24696+ },
24697+ Crumb {
24698+ label: list.name.clone().into(),
24699+ url: ListPath(list.id.to_string().into()).to_crumb(),
24700+ },
24701+ Crumb {
24702+ label: format!("{} {msg_id}", subject_ref).into(),
24703+ url: ListPostPath(list.id.to_string().into(), msg_id.to_string()).to_crumb(),
24704+ },
24705+ ];
24706+
24707+ let list_owners = db.list_owners(list.pk)?;
24708+ let mut list_obj = MailingList::from(list.clone());
24709+ list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
24710+
24711+ let context = minijinja::context! {
24712+ canonical_url => ListPostPath(ListPathIdentifier::from(list.id.clone()), msg_id.to_string().strip_carets_inplace()).to_crumb(),
24713+ page_title => subject_ref,
24714+ description => &list.description,
24715+ list => Value::from_object(list_obj),
24716+ pk => post.pk,
24717+ body => &body_text,
24718+ from => &envelope.field_from_to_string(),
24719+ date => &envelope.date_as_str(),
24720+ to => &envelope.field_to_to_string(),
24721+ subject => &envelope.subject(),
24722+ trimmed_subject => subject_ref,
24723+ in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().strip_carets_inplace()),
24724+ references => &envelope.references().into_iter().map(|m| m.to_string().strip_carets_inplace()).collect::<Vec<String>>(),
24725+ message_id => msg_id,
24726+ message => post.message,
24727+ timestamp => post.timestamp,
24728+ datetime => post.datetime,
24729+ thread => thread,
24730+ current_user => auth.current_user,
24731+ user_context => user_context,
24732+ messages => session.drain_messages(),
24733+ crumbs => crumbs,
24734+ };
24735+ Ok(Html(
24736+ TEMPLATES.get_template("lists/post.html")?.render(context)?,
24737+ ))
24738+ }
24739+
24740+ pub async fn list_edit(
24741+ ListEditPath(id): ListEditPath,
24742+ mut session: WritableSession,
24743+ auth: AuthContext,
24744+ State(state): State<Arc<AppState>>,
24745+ ) -> Result<Html<String>, ResponseError> {
24746+ let db = Connection::open_db(state.conf.clone())?;
24747+ let Some(list) = (match id {
24748+ ListPathIdentifier::Pk(id) => db.list(id)?,
24749+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
24750+ }) else {
24751+ return Err(ResponseError::new(
24752+ "Not found".to_string(),
24753+ StatusCode::NOT_FOUND,
24754+ ));
24755+ };
24756+ let list_owners = db.list_owners(list.pk)?;
24757+ let user_address = &auth.current_user.as_ref().unwrap().address;
24758+ if !list_owners.iter().any(|o| &o.address == user_address) {
24759+ return Err(ResponseError::new(
24760+ "Not found".to_string(),
24761+ StatusCode::NOT_FOUND,
24762+ ));
24763+ };
24764+
24765+ let post_policy = db.list_post_policy(list.pk)?;
24766+ let subscription_policy = db.list_subscription_policy(list.pk)?;
24767+ let post_count = {
24768+ let mut stmt = db
24769+ .connection
24770+ .prepare("SELECT count(*) FROM post WHERE list = ?;")?;
24771+ stmt.query_row([&list.pk], |row| {
24772+ let count: usize = row.get(0)?;
24773+ Ok(count)
24774+ })
24775+ .optional()?
24776+ .unwrap_or(0)
24777+ };
24778+ let subs_count = {
24779+ let mut stmt = db
24780+ .connection
24781+ .prepare("SELECT count(*) FROM subscription WHERE list = ?;")?;
24782+ stmt.query_row([&list.pk], |row| {
24783+ let count: usize = row.get(0)?;
24784+ Ok(count)
24785+ })
24786+ .optional()?
24787+ .unwrap_or(0)
24788+ };
24789+ let sub_requests_count = {
24790+ let mut stmt = db.connection.prepare(
24791+ "SELECT count(*) FROM candidate_subscription WHERE list = ? AND accepted IS NULL;",
24792+ )?;
24793+ stmt.query_row([&list.pk], |row| {
24794+ let count: usize = row.get(0)?;
24795+ Ok(count)
24796+ })
24797+ .optional()?
24798+ .unwrap_or(0)
24799+ };
24800+
24801+ let crumbs = vec![
24802+ Crumb {
24803+ label: "Home".into(),
24804+ url: "/".into(),
24805+ },
24806+ Crumb {
24807+ label: list.name.clone().into(),
24808+ url: ListPath(list.id.to_string().into()).to_crumb(),
24809+ },
24810+ Crumb {
24811+ label: format!("Edit {}", list.name).into(),
24812+ url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
24813+ },
24814+ ];
24815+ let list_owners = db.list_owners(list.pk)?;
24816+ let mut list_obj = MailingList::from(list.clone());
24817+ list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
24818+ let context = minijinja::context! {
24819+ canonical_url => ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
24820+ page_title => format!("Edit {} settings", list.name),
24821+ description => &list.description,
24822+ post_policy,
24823+ subscription_policy,
24824+ list_owners,
24825+ post_count,
24826+ subs_count,
24827+ sub_requests_count,
24828+ list => Value::from_object(list_obj),
24829+ current_user => auth.current_user,
24830+ messages => session.drain_messages(),
24831+ crumbs,
24832+ };
24833+ Ok(Html(
24834+ TEMPLATES.get_template("lists/edit.html")?.render(context)?,
24835+ ))
24836+ }
24837+
24838+ #[allow(non_snake_case)]
24839+ pub async fn list_edit_POST(
24840+ ListEditPath(id): ListEditPath,
24841+ mut session: WritableSession,
24842+ Extension(user): Extension<User>,
24843+ Form(payload): Form<ChangeSetting>,
24844+ State(state): State<Arc<AppState>>,
24845+ ) -> Result<Redirect, ResponseError> {
24846+ let db = Connection::open_db(state.conf.clone())?;
24847+ let Some(list) = (match id {
24848+ ListPathIdentifier::Pk(id) => db.list(id)?,
24849+ ListPathIdentifier::Id(ref id) => db.list_by_id(id)?,
24850+ }) else {
24851+ return Err(ResponseError::new(
24852+ "Not found".to_string(),
24853+ StatusCode::NOT_FOUND,
24854+ ));
24855+ };
24856+ let list_owners = db.list_owners(list.pk)?;
24857+ let user_address = &user.address;
24858+ if !list_owners.iter().any(|o| &o.address == user_address) {
24859+ return Err(ResponseError::new(
24860+ "Not found".to_string(),
24861+ StatusCode::NOT_FOUND,
24862+ ));
24863+ };
24864+
24865+ let db = db.trusted();
24866+ match payload {
24867+ ChangeSetting::PostPolicy {
24868+ delete_post_policy: _,
24869+ post_policy: val,
24870+ } => {
24871+ use PostPolicySettings::*;
24872+ session.add_message(
24873+ if let Err(err) = db.set_list_post_policy(mailpot::models::PostPolicy {
24874+ pk: -1,
24875+ list: list.pk,
24876+ announce_only: matches!(val, AnnounceOnly),
24877+ subscription_only: matches!(val, SubscriptionOnly),
24878+ approval_needed: matches!(val, ApprovalNeeded),
24879+ open: matches!(val, Open),
24880+ custom: matches!(val, Custom),
24881+ }) {
24882+ Message {
24883+ message: err.to_string().into(),
24884+ level: Level::Error,
24885+ }
24886+ } else {
24887+ Message {
24888+ message: "Post policy saved.".into(),
24889+ level: Level::Success,
24890+ }
24891+ },
24892+ )?;
24893+ }
24894+ ChangeSetting::SubscriptionPolicy {
24895+ send_confirmation: BoolPOST(send_confirmation),
24896+ subscription_policy: val,
24897+ } => {
24898+ use SubscriptionPolicySettings::*;
24899+ session.add_message(
24900+ if let Err(err) =
24901+ db.set_list_subscription_policy(mailpot::models::SubscriptionPolicy {
24902+ pk: -1,
24903+ list: list.pk,
24904+ send_confirmation,
24905+ open: matches!(val, Open),
24906+ manual: matches!(val, Manual),
24907+ request: matches!(val, Request),
24908+ custom: matches!(val, Custom),
24909+ })
24910+ {
24911+ Message {
24912+ message: err.to_string().into(),
24913+ level: Level::Error,
24914+ }
24915+ } else {
24916+ Message {
24917+ message: "Subscription policy saved.".into(),
24918+ level: Level::Success,
24919+ }
24920+ },
24921+ )?;
24922+ }
24923+ ChangeSetting::Metadata {
24924+ name,
24925+ id,
24926+ address,
24927+ description,
24928+ owner_local_part,
24929+ request_local_part,
24930+ archive_url,
24931+ } => {
24932+ session.add_message(
24933+ if let Err(err) =
24934+ db.update_list(mailpot::models::changesets::MailingListChangeset {
24935+ pk: list.pk,
24936+ name: Some(name),
24937+ id: Some(id),
24938+ address: Some(address),
24939+ description: description.map(|s| if s.is_empty() { None } else { Some(s) }),
24940+ owner_local_part: owner_local_part.map(|s| {
24941+ if s.is_empty() {
24942+ None
24943+ } else {
24944+ Some(s)
24945+ }
24946+ }),
24947+ request_local_part: request_local_part.map(|s| {
24948+ if s.is_empty() {
24949+ None
24950+ } else {
24951+ Some(s)
24952+ }
24953+ }),
24954+ archive_url: archive_url.map(|s| if s.is_empty() { None } else { Some(s) }),
24955+ ..Default::default()
24956+ })
24957+ {
24958+ Message {
24959+ message: err.to_string().into(),
24960+ level: Level::Error,
24961+ }
24962+ } else {
24963+ Message {
24964+ message: "List metadata saved.".into(),
24965+ level: Level::Success,
24966+ }
24967+ },
24968+ )?;
24969+ }
24970+ ChangeSetting::AcceptSubscriptionRequest { pk: IntPOST(pk) } => {
24971+ session.add_message(match db.accept_candidate_subscription(pk) {
24972+ Ok(subscription) => Message {
24973+ message: format!("Added: {subscription:#?}").into(),
24974+ level: Level::Success,
24975+ },
24976+ Err(err) => Message {
24977+ message: format!("Could not accept subscription request! Reason: {err}").into(),
24978+ level: Level::Error,
24979+ },
24980+ })?;
24981+ }
24982+ }
24983+
24984+ Ok(Redirect::to(&format!(
24985+ "{}{}",
24986+ &state.root_url_prefix,
24987+ ListEditPath(id).to_uri()
24988+ )))
24989+ }
24990+
24991+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
24992+ #[serde(tag = "type", rename_all = "kebab-case")]
24993+ pub enum ChangeSetting {
24994+ PostPolicy {
24995+ #[serde(rename = "delete-post-policy", default)]
24996+ delete_post_policy: Option<String>,
24997+ #[serde(rename = "post-policy")]
24998+ post_policy: PostPolicySettings,
24999+ },
25000+ SubscriptionPolicy {
25001+ #[serde(rename = "send-confirmation", default)]
25002+ send_confirmation: BoolPOST,
25003+ #[serde(rename = "subscription-policy")]
25004+ subscription_policy: SubscriptionPolicySettings,
25005+ },
25006+ Metadata {
25007+ name: String,
25008+ id: String,
25009+ #[serde(default)]
25010+ address: String,
25011+ #[serde(default)]
25012+ description: Option<String>,
25013+ #[serde(rename = "owner-local-part")]
25014+ #[serde(default)]
25015+ owner_local_part: Option<String>,
25016+ #[serde(rename = "request-local-part")]
25017+ #[serde(default)]
25018+ request_local_part: Option<String>,
25019+ #[serde(rename = "archive-url")]
25020+ #[serde(default)]
25021+ archive_url: Option<String>,
25022+ },
25023+ AcceptSubscriptionRequest {
25024+ pk: IntPOST,
25025+ },
25026+ }
25027+
25028+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
25029+ #[serde(rename_all = "kebab-case")]
25030+ pub enum PostPolicySettings {
25031+ AnnounceOnly,
25032+ SubscriptionOnly,
25033+ ApprovalNeeded,
25034+ Open,
25035+ Custom,
25036+ }
25037+
25038+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
25039+ #[serde(rename_all = "kebab-case")]
25040+ pub enum SubscriptionPolicySettings {
25041+ Open,
25042+ Manual,
25043+ Request,
25044+ Custom,
25045+ }
25046+
25047+ /// Raw post page.
25048+ pub async fn list_post_raw(
25049+ ListPostRawPath(id, msg_id): ListPostRawPath,
25050+ State(state): State<Arc<AppState>>,
25051+ ) -> Result<String, ResponseError> {
25052+ let db = Connection::open_db(state.conf.clone())?.trusted();
25053+ let Some(list) = (match id {
25054+ ListPathIdentifier::Pk(id) => db.list(id)?,
25055+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
25056+ }) else {
25057+ return Err(ResponseError::new(
25058+ "List not found".to_string(),
25059+ StatusCode::NOT_FOUND,
25060+ ));
25061+ };
25062+
25063+ let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
25064+ post
25065+ } else {
25066+ return Err(ResponseError::new(
25067+ format!("Post with Message-ID {} not found", msg_id),
25068+ StatusCode::NOT_FOUND,
25069+ ));
25070+ };
25071+ Ok(String::from_utf8_lossy(&post.message).to_string())
25072+ }
25073+
25074+ /// .eml post page.
25075+ pub async fn list_post_eml(
25076+ ListPostEmlPath(id, msg_id): ListPostEmlPath,
25077+ State(state): State<Arc<AppState>>,
25078+ ) -> Result<impl IntoResponse, ResponseError> {
25079+ let db = Connection::open_db(state.conf.clone())?.trusted();
25080+ let Some(list) = (match id {
25081+ ListPathIdentifier::Pk(id) => db.list(id)?,
25082+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
25083+ }) else {
25084+ return Err(ResponseError::new(
25085+ "List not found".to_string(),
25086+ StatusCode::NOT_FOUND,
25087+ ));
25088+ };
25089+
25090+ let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
25091+ post
25092+ } else {
25093+ return Err(ResponseError::new(
25094+ format!("Post with Message-ID {} not found", msg_id),
25095+ StatusCode::NOT_FOUND,
25096+ ));
25097+ };
25098+ let mut response = post.into_inner().message.into_response();
25099+ response.headers_mut().insert(
25100+ http::header::CONTENT_TYPE,
25101+ http::HeaderValue::from_static("application/octet-stream"),
25102+ );
25103+ response.headers_mut().insert(
25104+ http::header::CONTENT_DISPOSITION,
25105+ http::HeaderValue::try_from(format!(
25106+ "attachment; filename=\"{}.eml\"",
25107+ msg_id.trim().strip_carets()
25108+ ))
25109+ .unwrap(),
25110+ );
25111+
25112+ Ok(response)
25113+ }
25114+
25115+ pub async fn list_subscribers(
25116+ ListEditSubscribersPath(id): ListEditSubscribersPath,
25117+ mut session: WritableSession,
25118+ auth: AuthContext,
25119+ State(state): State<Arc<AppState>>,
25120+ ) -> Result<Html<String>, ResponseError> {
25121+ let db = Connection::open_db(state.conf.clone())?;
25122+ let Some(list) = (match id {
25123+ ListPathIdentifier::Pk(id) => db.list(id)?,
25124+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
25125+ }) else {
25126+ return Err(ResponseError::new(
25127+ "Not found".to_string(),
25128+ StatusCode::NOT_FOUND,
25129+ ));
25130+ };
25131+ let list_owners = db.list_owners(list.pk)?;
25132+ let user_address = &auth.current_user.as_ref().unwrap().address;
25133+ if !list_owners.iter().any(|o| &o.address == user_address) {
25134+ return Err(ResponseError::new(
25135+ "Not found".to_string(),
25136+ StatusCode::NOT_FOUND,
25137+ ));
25138+ };
25139+
25140+ let subs = {
25141+ let mut stmt = db
25142+ .connection
25143+ .prepare("SELECT * FROM subscription WHERE list = ?;")?;
25144+ let iter = stmt.query_map([&list.pk], |row| {
25145+ let address: String = row.get("address")?;
25146+ let name: Option<String> = row.get("name")?;
25147+ let enabled: bool = row.get("enabled")?;
25148+ let verified: bool = row.get("verified")?;
25149+ let digest: bool = row.get("digest")?;
25150+ let hide_address: bool = row.get("hide_address")?;
25151+ let receive_duplicates: bool = row.get("receive_duplicates")?;
25152+ let receive_own_posts: bool = row.get("receive_own_posts")?;
25153+ let receive_confirmation: bool = row.get("receive_confirmation")?;
25154+ //let last_digest: i64 = row.get("last_digest")?;
25155+ let created: i64 = row.get("created")?;
25156+ let last_modified: i64 = row.get("last_modified")?;
25157+ Ok(minijinja::context! {
25158+ address,
25159+ name,
25160+ enabled,
25161+ verified,
25162+ digest,
25163+ hide_address,
25164+ receive_duplicates,
25165+ receive_own_posts,
25166+ receive_confirmation,
25167+ //last_digest => chrono::Utc.timestamp_opt(last_digest, 0).unwrap().to_string(),
25168+ created => chrono::Utc.timestamp_opt(created, 0).unwrap().to_string(),
25169+ last_modified => chrono::Utc.timestamp_opt(last_modified, 0).unwrap().to_string(),
25170+ })
25171+ })?;
25172+ let mut ret = vec![];
25173+ for el in iter {
25174+ let el = el?;
25175+ ret.push(el);
25176+ }
25177+ ret
25178+ };
25179+
25180+ let crumbs = vec![
25181+ Crumb {
25182+ label: "Home".into(),
25183+ url: "/".into(),
25184+ },
25185+ Crumb {
25186+ label: list.name.clone().into(),
25187+ url: ListPath(list.id.to_string().into()).to_crumb(),
25188+ },
25189+ Crumb {
25190+ label: format!("Edit {}", list.name).into(),
25191+ url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
25192+ },
25193+ Crumb {
25194+ label: format!("Subscribers of {}", list.name).into(),
25195+ url: ListEditSubscribersPath(list.id.to_string().into()).to_crumb(),
25196+ },
25197+ ];
25198+ let list_owners = db.list_owners(list.pk)?;
25199+ let mut list_obj = MailingList::from(list.clone());
25200+ list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
25201+ let context = minijinja::context! {
25202+ canonical_url => ListEditSubscribersPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
25203+ page_title => format!("Subscribers of {}", list.name),
25204+ subs,
25205+ list => Value::from_object(list_obj),
25206+ current_user => auth.current_user,
25207+ messages => session.drain_messages(),
25208+ crumbs,
25209+ };
25210+ Ok(Html(
25211+ TEMPLATES.get_template("lists/subs.html")?.render(context)?,
25212+ ))
25213+ }
25214+
25215+ pub async fn list_candidates(
25216+ ListEditCandidatesPath(id): ListEditCandidatesPath,
25217+ mut session: WritableSession,
25218+ auth: AuthContext,
25219+ State(state): State<Arc<AppState>>,
25220+ ) -> Result<Html<String>, ResponseError> {
25221+ let db = Connection::open_db(state.conf.clone())?;
25222+ let Some(list) = (match id {
25223+ ListPathIdentifier::Pk(id) => db.list(id)?,
25224+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
25225+ }) else {
25226+ return Err(ResponseError::new(
25227+ "Not found".to_string(),
25228+ StatusCode::NOT_FOUND,
25229+ ));
25230+ };
25231+ let list_owners = db.list_owners(list.pk)?;
25232+ let user_address = &auth.current_user.as_ref().unwrap().address;
25233+ if !list_owners.iter().any(|o| &o.address == user_address) {
25234+ return Err(ResponseError::new(
25235+ "Not found".to_string(),
25236+ StatusCode::NOT_FOUND,
25237+ ));
25238+ };
25239+
25240+ let subs = {
25241+ let mut stmt = db
25242+ .connection
25243+ .prepare("SELECT * FROM candidate_subscription WHERE list = ?;")?;
25244+ let iter = stmt.query_map([&list.pk], |row| {
25245+ let pk: i64 = row.get("pk")?;
25246+ let address: String = row.get("address")?;
25247+ let name: Option<String> = row.get("name")?;
25248+ let accepted: Option<i64> = row.get("accepted")?;
25249+ let created: i64 = row.get("created")?;
25250+ let last_modified: i64 = row.get("last_modified")?;
25251+ Ok(minijinja::context! {
25252+ pk,
25253+ address,
25254+ name,
25255+ accepted => accepted.is_some(),
25256+ created => chrono::Utc.timestamp_opt(created, 0).unwrap().to_string(),
25257+ last_modified => chrono::Utc.timestamp_opt(last_modified, 0).unwrap().to_string(),
25258+ })
25259+ })?;
25260+ let mut ret = vec![];
25261+ for el in iter {
25262+ let el = el?;
25263+ ret.push(el);
25264+ }
25265+ ret
25266+ };
25267+
25268+ let crumbs = vec![
25269+ Crumb {
25270+ label: "Home".into(),
25271+ url: "/".into(),
25272+ },
25273+ Crumb {
25274+ label: list.name.clone().into(),
25275+ url: ListPath(list.id.to_string().into()).to_crumb(),
25276+ },
25277+ Crumb {
25278+ label: format!("Edit {}", list.name).into(),
25279+ url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
25280+ },
25281+ Crumb {
25282+ label: format!("Requests of {}", list.name).into(),
25283+ url: ListEditCandidatesPath(list.id.to_string().into()).to_crumb(),
25284+ },
25285+ ];
25286+ let mut list_obj: MailingList = MailingList::from(list.clone());
25287+ list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
25288+ let context = minijinja::context! {
25289+ canonical_url => ListEditCandidatesPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
25290+ page_title => format!("Requests of {}", list.name),
25291+ subs,
25292+ list => Value::from_object(list_obj),
25293+ current_user => auth.current_user,
25294+ messages => session.drain_messages(),
25295+ crumbs,
25296+ };
25297+ Ok(Html(
25298+ TEMPLATES
25299+ .get_template("lists/sub-requests.html")?
25300+ .render(context)?,
25301+ ))
25302+ }
25303 diff --git a/mailpot-web/src/main.rs b/mailpot-web/src/main.rs
25304new file mode 100644
25305index 0000000..0882abc
25306--- /dev/null
25307+++ b/mailpot-web/src/main.rs
25308 @@ -0,0 +1,554 @@
25309+ /*
25310+ * This file is part of mailpot
25311+ *
25312+ * Copyright 2020 - Manos Pitsidianakis
25313+ *
25314+ * This program is free software: you can redistribute it and/or modify
25315+ * it under the terms of the GNU Affero General Public License as
25316+ * published by the Free Software Foundation, either version 3 of the
25317+ * License, or (at your option) any later version.
25318+ *
25319+ * This program is distributed in the hope that it will be useful,
25320+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
25321+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25322+ * GNU Affero General Public License for more details.
25323+ *
25324+ * You should have received a copy of the GNU Affero General Public License
25325+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
25326+ */
25327+
25328+ use std::{collections::HashMap, sync::Arc};
25329+
25330+ use chrono::TimeZone;
25331+ use mailpot::{log, Configuration, Connection};
25332+ use mailpot_web::*;
25333+ use minijinja::value::Value;
25334+ use rand::Rng;
25335+ use tokio::sync::RwLock;
25336+
25337+ fn new_state(conf: Configuration) -> Arc<AppState> {
25338+ Arc::new(AppState {
25339+ conf,
25340+ root_url_prefix: Value::from_safe_string(
25341+ std::env::var("ROOT_URL_PREFIX").unwrap_or_default(),
25342+ ),
25343+ public_url: std::env::var("PUBLIC_URL").unwrap_or_else(|_| "lists.mailpot.rs".to_string()),
25344+ site_title: std::env::var("SITE_TITLE")
25345+ .unwrap_or_else(|_| "mailing list archive".to_string())
25346+ .into(),
25347+ site_subtitle: std::env::var("SITE_SUBTITLE").ok().map(Into::into),
25348+ user_store: Arc::new(RwLock::new(HashMap::default())),
25349+ })
25350+ }
25351+
25352+ fn create_app(shared_state: Arc<AppState>) -> Router {
25353+ let store = MemoryStore::new();
25354+ let secret = rand::thread_rng().gen::<[u8; 128]>();
25355+ let session_layer = SessionLayer::new(store, &secret).with_secure(false);
25356+
25357+ let auth_layer = AuthLayer::new(shared_state.clone(), &secret);
25358+
25359+ let login_url =
25360+ Arc::new(format!("{}{}", shared_state.root_url_prefix, LoginPath.to_crumb()).into());
25361+ Router::new()
25362+ .route("/", get(root))
25363+ .typed_get(list)
25364+ .typed_get(list_post)
25365+ .typed_get(list_post_raw)
25366+ .typed_get(list_topics)
25367+ .typed_get(list_post_eml)
25368+ .typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect(
25369+ Role::User..,
25370+ Arc::clone(&login_url),
25371+ Some(Arc::new("next".into())),
25372+ )))
25373+ .typed_post(
25374+ {
25375+ let shared_state = Arc::clone(&shared_state);
25376+ move |path, session, user, payload| {
25377+ list_edit_POST(path, session, user, payload, State(shared_state))
25378+ }
25379+ }
25380+ .layer(RequireAuth::login_with_role_or_redirect(
25381+ Role::User..,
25382+ Arc::clone(&login_url),
25383+ Some(Arc::new("next".into())),
25384+ )),
25385+ )
25386+ .typed_get(
25387+ list_subscribers.layer(RequireAuth::login_with_role_or_redirect(
25388+ Role::User..,
25389+ Arc::clone(&login_url),
25390+ Some(Arc::new("next".into())),
25391+ )),
25392+ )
25393+ .typed_get(
25394+ list_candidates.layer(RequireAuth::login_with_role_or_redirect(
25395+ Role::User..,
25396+ Arc::clone(&login_url),
25397+ Some(Arc::new("next".into())),
25398+ )),
25399+ )
25400+ .typed_get(help)
25401+ .typed_get(auth::ssh_signin)
25402+ .typed_post({
25403+ let shared_state = Arc::clone(&shared_state);
25404+ move |path, session, query, auth, body| {
25405+ auth::ssh_signin_POST(path, session, query, auth, body, shared_state)
25406+ }
25407+ })
25408+ .typed_get(logout_handler)
25409+ .typed_post(logout_handler)
25410+ .typed_get(
25411+ {
25412+ let shared_state = Arc::clone(&shared_state);
25413+ move |path, session, user| settings(path, session, user, shared_state)
25414+ }
25415+ .layer(RequireAuth::login_or_redirect(
25416+ Arc::clone(&login_url),
25417+ Some(Arc::new("next".into())),
25418+ )),
25419+ )
25420+ .typed_post(
25421+ {
25422+ let shared_state = Arc::clone(&shared_state);
25423+ move |path, session, auth, body| {
25424+ settings_POST(path, session, auth, body, shared_state)
25425+ }
25426+ }
25427+ .layer(RequireAuth::login_or_redirect(
25428+ Arc::clone(&login_url),
25429+ Some(Arc::new("next".into())),
25430+ )),
25431+ )
25432+ .typed_get(
25433+ user_list_subscription.layer(RequireAuth::login_with_role_or_redirect(
25434+ Role::User..,
25435+ Arc::clone(&login_url),
25436+ Some(Arc::new("next".into())),
25437+ )),
25438+ )
25439+ .typed_post(
25440+ {
25441+ let shared_state = Arc::clone(&shared_state);
25442+ move |session, path, user, body| {
25443+ user_list_subscription_POST(session, path, user, body, shared_state)
25444+ }
25445+ }
25446+ .layer(RequireAuth::login_with_role_or_redirect(
25447+ Role::User..,
25448+ Arc::clone(&login_url),
25449+ Some(Arc::new("next".into())),
25450+ )),
25451+ )
25452+ .layer(auth_layer)
25453+ .layer(session_layer)
25454+ .with_state(shared_state)
25455+ }
25456+
25457+ #[tokio::main]
25458+ async fn main() {
25459+ let config_path = std::env::args()
25460+ .nth(1)
25461+ .expect("Expected configuration file path as first argument.");
25462+ if ["-v", "--version", "info"].contains(&config_path.as_str()) {
25463+ println!("{}", crate::get_git_sha());
25464+ println!("{CLI_INFO}");
25465+
25466+ return;
25467+ }
25468+ #[cfg(test)]
25469+ let verbosity = log::LevelFilter::Trace;
25470+ #[cfg(not(test))]
25471+ let verbosity = log::LevelFilter::Info;
25472+ stderrlog::new()
25473+ .quiet(false)
25474+ .verbosity(verbosity)
25475+ .show_module_names(true)
25476+ .timestamp(stderrlog::Timestamp::Millisecond)
25477+ .init()
25478+ .unwrap();
25479+ let conf = Configuration::from_file(config_path).unwrap();
25480+ let app = create_app(new_state(conf));
25481+
25482+ let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "0.0.0.0".to_string());
25483+ let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
25484+ let listen_to = format!("{hostname}:{port}");
25485+ println!("Listening to {listen_to}...");
25486+ axum::Server::bind(&listen_to.parse().unwrap())
25487+ .serve(app.into_make_service())
25488+ .await
25489+ .unwrap();
25490+ }
25491+
25492+ async fn root(
25493+ mut session: WritableSession,
25494+ auth: AuthContext,
25495+ State(state): State<Arc<AppState>>,
25496+ ) -> Result<Html<String>, ResponseError> {
25497+ let db = Connection::open_db(state.conf.clone())?;
25498+ let lists_values = db.lists()?;
25499+ let lists = lists_values
25500+ .iter()
25501+ .map(|list| {
25502+ let months = db.months(list.pk)?;
25503+ let posts = db.list_posts(list.pk, None)?;
25504+ let newest = posts.last().and_then(|p| {
25505+ chrono::Utc
25506+ .timestamp_opt(p.timestamp as i64, 0)
25507+ .earliest()
25508+ .map(|d| d.to_rfc3339())
25509+ });
25510+ let list_owners = db.list_owners(list.pk)?;
25511+ let mut list_obj = MailingList::from(list.clone());
25512+ list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
25513+ Ok(minijinja::context! {
25514+ newest,
25515+ posts => &posts,
25516+ months => &months,
25517+ list => Value::from_object(list_obj),
25518+ })
25519+ })
25520+ .collect::<Result<Vec<_>, mailpot::Error>>()?;
25521+ let crumbs = vec![Crumb {
25522+ label: "Home".into(),
25523+ url: "/".into(),
25524+ }];
25525+
25526+ let context = minijinja::context! {
25527+ page_title => Option::<&'static str>::None,
25528+ lists => &lists,
25529+ current_user => auth.current_user,
25530+ messages => session.drain_messages(),
25531+ crumbs => crumbs,
25532+ };
25533+ Ok(Html(TEMPLATES.get_template("lists.html")?.render(context)?))
25534+ }
25535+
25536+ #[cfg(test)]
25537+ mod tests {
25538+
25539+ use axum::{
25540+ body::Body,
25541+ http::{
25542+ header::{COOKIE, SET_COOKIE},
25543+ method::Method,
25544+ Request, StatusCode,
25545+ },
25546+ };
25547+ use mailpot::{Configuration, Connection, SendMail};
25548+ use mailpot_tests::init_stderr_logging;
25549+ use percent_encoding::utf8_percent_encode;
25550+ use tempfile::TempDir;
25551+ use tower::ServiceExt;
25552+
25553+ use super::*;
25554+
25555+ #[tokio::test]
25556+ async fn test_routes() {
25557+ #![cfg_attr(not(debug_assertions), allow(unreachable_code))]
25558+
25559+ init_stderr_logging();
25560+
25561+ macro_rules! req {
25562+ (get $url:expr) => {{
25563+ Request::builder()
25564+ .uri($url)
25565+ .method(Method::GET)
25566+ .body(Body::empty())
25567+ .unwrap()
25568+ }};
25569+ (post $url:expr, $body:expr) => {{
25570+ Request::builder()
25571+ .uri($url)
25572+ .method(Method::POST)
25573+ .header("Content-Type", "application/x-www-form-urlencoded")
25574+ .body(Body::from(
25575+ serde_urlencoded::to_string($body).unwrap().into_bytes(),
25576+ ))
25577+ .unwrap()
25578+ }};
25579+ }
25580+
25581+ let tmp_dir = TempDir::new().unwrap();
25582+
25583+ let db_path = tmp_dir.path().join("mpot.db");
25584+ std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
25585+ let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
25586+ #[allow(clippy::permissions_set_readonly_false)]
25587+ perms.set_readonly(false);
25588+ std::fs::set_permissions(&db_path, perms).unwrap();
25589+
25590+ let config = Configuration {
25591+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
25592+ db_path,
25593+ data_path: tmp_dir.path().to_path_buf(),
25594+ administrators: vec![],
25595+ };
25596+ let db = Connection::open_db(config.clone()).unwrap();
25597+ let list = db.lists().unwrap().remove(0);
25598+
25599+ let state = new_state(config.clone());
25600+
25601+ // ------------------------------------------------------------
25602+ // list()
25603+
25604+ let cl = |url, state| async move {
25605+ let response = create_app(state).oneshot(req!(get & url)).await.unwrap();
25606+
25607+ assert_eq!(response.status(), StatusCode::OK);
25608+
25609+ hyper::body::to_bytes(response.into_body()).await.unwrap()
25610+ };
25611+ assert_eq!(
25612+ cl(format!("/list/{}/", list.id), state.clone()).await,
25613+ cl(format!("/list/{}/", list.pk), state.clone()).await
25614+ );
25615+
25616+ // ------------------------------------------------------------
25617+ // list_post(), list_post_eml(), list_post_raw()
25618+
25619+ {
25620+ let msg_id = "<abcdefgh@sator.example.com>";
25621+ let res = create_app(state.clone())
25622+ .oneshot(req!(
25623+ get & format!(
25624+ "/list/{id}/posts/{msgid}/",
25625+ id = list.id,
25626+ msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
25627+ )
25628+ ))
25629+ .await
25630+ .unwrap();
25631+
25632+ assert_eq!(res.status(), StatusCode::OK);
25633+ assert_eq!(
25634+ res.headers().get(http::header::CONTENT_TYPE),
25635+ Some(&http::HeaderValue::from_static("text/html; charset=utf-8"))
25636+ );
25637+ let res = create_app(state.clone())
25638+ .oneshot(req!(
25639+ get & format!(
25640+ "/list/{id}/posts/{msgid}/raw/",
25641+ id = list.id,
25642+ msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
25643+ )
25644+ ))
25645+ .await
25646+ .unwrap();
25647+
25648+ assert_eq!(res.status(), StatusCode::OK);
25649+ assert_eq!(
25650+ res.headers().get(http::header::CONTENT_TYPE),
25651+ Some(&http::HeaderValue::from_static("text/plain; charset=utf-8"))
25652+ );
25653+ let res = create_app(state.clone())
25654+ .oneshot(req!(
25655+ get & format!(
25656+ "/list/{id}/posts/{msgid}/eml/",
25657+ id = list.id,
25658+ msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
25659+ )
25660+ ))
25661+ .await
25662+ .unwrap();
25663+
25664+ assert_eq!(res.status(), StatusCode::OK);
25665+ assert_eq!(
25666+ res.headers().get(http::header::CONTENT_TYPE),
25667+ Some(&http::HeaderValue::from_static("application/octet-stream"))
25668+ );
25669+ assert_eq!(
25670+ res.headers().get(http::header::CONTENT_DISPOSITION),
25671+ Some(&http::HeaderValue::from_static(
25672+ "attachment; filename=\"abcdefgh@sator.example.com.eml\""
25673+ )),
25674+ );
25675+ }
25676+ // ------------------------------------------------------------
25677+ // help(), ssh_signin(), root()
25678+
25679+ for path in ["/help/", "/"] {
25680+ let response = create_app(state.clone())
25681+ .oneshot(req!(get path))
25682+ .await
25683+ .unwrap();
25684+
25685+ assert_eq!(response.status(), StatusCode::OK);
25686+ }
25687+
25688+ #[cfg(not(debug_assertions))]
25689+ return;
25690+ // ------------------------------------------------------------
25691+ // auth.rs...
25692+
25693+ let login_app = create_app(state.clone());
25694+ let session_cookie = {
25695+ let response = login_app
25696+ .clone()
25697+ .oneshot(req!(get "/login/"))
25698+ .await
25699+ .unwrap();
25700+ assert_eq!(response.status(), StatusCode::OK);
25701+
25702+ response.headers().get(SET_COOKIE).unwrap().clone()
25703+ };
25704+ let user = User {
25705+ pk: 1,
25706+ ssh_signature: String::new(),
25707+ role: Role::User,
25708+ public_key: None,
25709+ password: String::new(),
25710+ name: None,
25711+ address: String::new(),
25712+ enabled: true,
25713+ };
25714+ state.insert_user(1, user.clone()).await;
25715+
25716+ {
25717+ let mut request = req!(post "/login/",
25718+ AuthFormPayload {
25719+ address: "user@example.com".into(),
25720+ password: "hunter2".into()
25721+ }
25722+ );
25723+ request
25724+ .headers_mut()
25725+ .insert(COOKIE, session_cookie.to_owned());
25726+ let res = login_app.clone().oneshot(request).await.unwrap();
25727+
25728+ assert_eq!(
25729+ res.headers().get(http::header::LOCATION),
25730+ Some(
25731+ &SettingsPath
25732+ .to_uri()
25733+ .to_string()
25734+ .as_str()
25735+ .try_into()
25736+ .unwrap()
25737+ )
25738+ );
25739+ }
25740+
25741+ // ------------------------------------------------------------
25742+ // settings()
25743+
25744+ {
25745+ let mut request = req!(get "/settings/");
25746+ request
25747+ .headers_mut()
25748+ .insert(COOKIE, session_cookie.to_owned());
25749+ let response = login_app.clone().oneshot(request).await.unwrap();
25750+
25751+ assert_eq!(response.status(), StatusCode::OK);
25752+ }
25753+
25754+ // ------------------------------------------------------------
25755+ // settings_post()
25756+
25757+ {
25758+ let mut request = req!(
25759+ post "/settings/",
25760+ crate::settings::ChangeSetting::Subscribe {
25761+ list_pk: IntPOST(1),
25762+ });
25763+ request
25764+ .headers_mut()
25765+ .insert(COOKIE, session_cookie.to_owned());
25766+ let res = login_app.clone().oneshot(request).await.unwrap();
25767+
25768+ assert_eq!(
25769+ res.headers().get(http::header::LOCATION),
25770+ Some(
25771+ &SettingsPath
25772+ .to_uri()
25773+ .to_string()
25774+ .as_str()
25775+ .try_into()
25776+ .unwrap()
25777+ )
25778+ );
25779+ }
25780+ // ------------------------------------------------------------
25781+ // user_list_subscription() TODO
25782+
25783+ // ------------------------------------------------------------
25784+ // user_list_subscription_post() TODO
25785+
25786+ // ------------------------------------------------------------
25787+ // list_edit()
25788+
25789+ {
25790+ let mut request = req!(get & format!("/list/{id}/edit/", id = list.id,));
25791+ request
25792+ .headers_mut()
25793+ .insert(COOKIE, session_cookie.to_owned());
25794+ let response = login_app.clone().oneshot(request).await.unwrap();
25795+
25796+ assert_eq!(response.status(), StatusCode::OK);
25797+ }
25798+
25799+ // ------------------------------------------------------------
25800+ // list_edit_POST()
25801+
25802+ {
25803+ let mut request = req!(
25804+ post & format!("/list/{id}/edit/", id = list.id,),
25805+ crate::lists::ChangeSetting::Metadata {
25806+ name: "new name".to_string(),
25807+ id: "new-name".to_string(),
25808+ address: list.address.clone(),
25809+ description: list.description.clone(),
25810+ owner_local_part: None,
25811+ request_local_part: None,
25812+ archive_url: None,
25813+ }
25814+ );
25815+ request
25816+ .headers_mut()
25817+ .insert(COOKIE, session_cookie.to_owned());
25818+ let response = login_app.clone().oneshot(request).await.unwrap();
25819+
25820+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
25821+ let list_mod = db.lists().unwrap().remove(0);
25822+ assert_eq!(&list_mod.name, "new name");
25823+ assert_eq!(&list_mod.id, "new-name");
25824+ assert_eq!(&list_mod.address, &list.address);
25825+ assert_eq!(&list_mod.description, &list.description);
25826+ }
25827+
25828+ {
25829+ let mut request = req!(post "/list/new-name/edit/",
25830+ crate::lists::ChangeSetting::SubscriptionPolicy {
25831+ send_confirmation: BoolPOST(false),
25832+ subscription_policy: crate::lists::SubscriptionPolicySettings::Custom,
25833+ }
25834+ );
25835+ request
25836+ .headers_mut()
25837+ .insert(COOKIE, session_cookie.to_owned());
25838+ let response = login_app.clone().oneshot(request).await.unwrap();
25839+
25840+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
25841+ let policy = db.list_subscription_policy(list.pk()).unwrap().unwrap();
25842+ assert!(!policy.send_confirmation);
25843+ assert!(policy.custom);
25844+ }
25845+ {
25846+ let mut request = req!(post "/list/new-name/edit/",
25847+ crate::lists::ChangeSetting::PostPolicy {
25848+ delete_post_policy: None,
25849+ post_policy: crate::lists::PostPolicySettings::Custom,
25850+ }
25851+ );
25852+ request
25853+ .headers_mut()
25854+ .insert(COOKIE, session_cookie.to_owned());
25855+ let response = login_app.clone().oneshot(request).await.unwrap();
25856+
25857+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
25858+ let policy = db.list_post_policy(list.pk()).unwrap().unwrap();
25859+ assert!(policy.custom);
25860+ }
25861+ }
25862+ }
25863 diff --git a/mailpot-web/src/minijinja_utils.rs b/mailpot-web/src/minijinja_utils.rs
25864new file mode 100644
25865index 0000000..5238343
25866--- /dev/null
25867+++ b/mailpot-web/src/minijinja_utils.rs
25868 @@ -0,0 +1,893 @@
25869+ /*
25870+ * This file is part of mailpot
25871+ *
25872+ * Copyright 2020 - Manos Pitsidianakis
25873+ *
25874+ * This program is free software: you can redistribute it and/or modify
25875+ * it under the terms of the GNU Affero General Public License as
25876+ * published by the Free Software Foundation, either version 3 of the
25877+ * License, or (at your option) any later version.
25878+ *
25879+ * This program is distributed in the hope that it will be useful,
25880+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
25881+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25882+ * GNU Affero General Public License for more details.
25883+ *
25884+ * You should have received a copy of the GNU Affero General Public License
25885+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
25886+ */
25887+
25888+ //! Utils for templates with the [`minijinja`] crate.
25889+
25890+ use std::fmt::Write;
25891+
25892+ use mailpot::models::ListOwner;
25893+ pub use mailpot::StripCarets;
25894+
25895+ use super::*;
25896+
25897+ mod compressed;
25898+
25899+ lazy_static::lazy_static! {
25900+ pub static ref TEMPLATES: Environment<'static> = {
25901+ let mut env = Environment::new();
25902+ macro_rules! add {
25903+ (function $($id:ident),*$(,)?) => {
25904+ $(env.add_function(stringify!($id), $id);)*
25905+ };
25906+ (filter $($id:ident),*$(,)?) => {
25907+ $(env.add_filter(stringify!($id), $id);)*
25908+ }
25909+ }
25910+ add!(function calendarize,
25911+ strip_carets,
25912+ urlize,
25913+ heading,
25914+ topics,
25915+ login_path,
25916+ logout_path,
25917+ settings_path,
25918+ help_path,
25919+ list_path,
25920+ list_settings_path,
25921+ list_edit_path,
25922+ list_subscribers_path,
25923+ list_candidates_path,
25924+ list_post_path,
25925+ post_raw_path,
25926+ post_eml_path
25927+ );
25928+ add!(filter pluralize);
25929+ // Load compressed templates. They are constructed in build.rs. See
25930+ // [ref:embed_templates]
25931+ let mut source = minijinja::Source::new();
25932+ for (name, bytes) in compressed::COMPRESSED {
25933+ let mut de_bytes = vec![];
25934+ zstd::stream::copy_decode(*bytes,&mut de_bytes).unwrap();
25935+ source.add_template(*name, String::from_utf8(de_bytes).unwrap()).unwrap();
25936+ }
25937+ env.set_source(source);
25938+
25939+ env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
25940+ env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
25941+ env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
25942+ env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
25943+
25944+ env
25945+ };
25946+ }
25947+
25948+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
25949+ pub struct MailingList {
25950+ pub pk: i64,
25951+ pub name: String,
25952+ pub id: String,
25953+ pub address: String,
25954+ pub description: Option<String>,
25955+ pub topics: Vec<String>,
25956+ #[serde(serialize_with = "super::utils::to_safe_string_opt")]
25957+ pub archive_url: Option<String>,
25958+ pub inner: DbVal<mailpot::models::MailingList>,
25959+ #[serde(default)]
25960+ pub is_description_html_safe: bool,
25961+ }
25962+
25963+ impl MailingList {
25964+ /// Set whether it's safe to not escape the list's description field.
25965+ ///
25966+ /// If anyone can display arbitrary html in the server, that's bad.
25967+ ///
25968+ /// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
25969+ /// `ListOwner` slices.
25970+ pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
25971+ &mut self,
25972+ owners: &[O],
25973+ administrators: &[String],
25974+ ) {
25975+ if owners.is_empty() || administrators.is_empty() {
25976+ return;
25977+ }
25978+ self.is_description_html_safe = owners
25979+ .iter()
25980+ .any(|o| administrators.contains(&o.borrow().address));
25981+ }
25982+ }
25983+
25984+ impl From<DbVal<mailpot::models::MailingList>> for MailingList {
25985+ fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
25986+ let DbVal(
25987+ mailpot::models::MailingList {
25988+ pk,
25989+ name,
25990+ id,
25991+ address,
25992+ description,
25993+ topics,
25994+ archive_url,
25995+ },
25996+ _,
25997+ ) = val.clone();
25998+
25999+ Self {
26000+ pk,
26001+ name,
26002+ id,
26003+ address,
26004+ description,
26005+ topics,
26006+ archive_url,
26007+ inner: val,
26008+ is_description_html_safe: false,
26009+ }
26010+ }
26011+ }
26012+
26013+ impl std::fmt::Display for MailingList {
26014+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
26015+ self.id.fmt(fmt)
26016+ }
26017+ }
26018+
26019+ impl Object for MailingList {
26020+ fn kind(&self) -> minijinja::value::ObjectKind {
26021+ minijinja::value::ObjectKind::Struct(self)
26022+ }
26023+
26024+ fn call_method(
26025+ &self,
26026+ _state: &minijinja::State,
26027+ name: &str,
26028+ _args: &[Value],
26029+ ) -> std::result::Result<Value, Error> {
26030+ match name {
26031+ "subscription_mailto" => {
26032+ Ok(Value::from_serializable(&self.inner.subscription_mailto()))
26033+ }
26034+ "unsubscription_mailto" => Ok(Value::from_serializable(
26035+ &self.inner.unsubscription_mailto(),
26036+ )),
26037+ "topics" => topics_common(&self.topics),
26038+ _ => Err(Error::new(
26039+ minijinja::ErrorKind::UnknownMethod,
26040+ format!("object has no method named {name}"),
26041+ )),
26042+ }
26043+ }
26044+ }
26045+
26046+ impl minijinja::value::StructObject for MailingList {
26047+ fn get_field(&self, name: &str) -> Option<Value> {
26048+ match name {
26049+ "pk" => Some(Value::from_serializable(&self.pk)),
26050+ "name" => Some(Value::from_serializable(&self.name)),
26051+ "id" => Some(Value::from_serializable(&self.id)),
26052+ "address" => Some(Value::from_serializable(&self.address)),
26053+ "description" if self.is_description_html_safe => {
26054+ self.description.as_ref().map_or_else(
26055+ || Some(Value::from_serializable(&self.description)),
26056+ |d| Some(Value::from_safe_string(d.clone())),
26057+ )
26058+ }
26059+ "description" => Some(Value::from_serializable(&self.description)),
26060+ "topics" => Some(Value::from_serializable(&self.topics)),
26061+ "archive_url" => Some(Value::from_serializable(&self.archive_url)),
26062+ "is_description_html_safe" => {
26063+ Some(Value::from_serializable(&self.is_description_html_safe))
26064+ }
26065+ _ => None,
26066+ }
26067+ }
26068+
26069+ fn static_fields(&self) -> Option<&'static [&'static str]> {
26070+ Some(
26071+ &[
26072+ "pk",
26073+ "name",
26074+ "id",
26075+ "address",
26076+ "description",
26077+ "topics",
26078+ "archive_url",
26079+ "is_description_html_safe",
26080+ ][..],
26081+ )
26082+ }
26083+ }
26084+
26085+ /// Return a vector of weeks, with each week being a vector of 7 days and
26086+ /// corresponding sum of posts per day.
26087+ pub fn calendarize(
26088+ _state: &minijinja::State,
26089+ args: Value,
26090+ hists: Value,
26091+ ) -> std::result::Result<Value, Error> {
26092+ use chrono::Month;
26093+
26094+ macro_rules! month {
26095+ ($int:expr) => {{
26096+ let int = $int;
26097+ match int {
26098+ 1 => Month::January.name(),
26099+ 2 => Month::February.name(),
26100+ 3 => Month::March.name(),
26101+ 4 => Month::April.name(),
26102+ 5 => Month::May.name(),
26103+ 6 => Month::June.name(),
26104+ 7 => Month::July.name(),
26105+ 8 => Month::August.name(),
26106+ 9 => Month::September.name(),
26107+ 10 => Month::October.name(),
26108+ 11 => Month::November.name(),
26109+ 12 => Month::December.name(),
26110+ _ => unreachable!(),
26111+ }
26112+ }};
26113+ }
26114+ let month = args.as_str().unwrap();
26115+ let hist = hists
26116+ .get_item(&Value::from(month))?
26117+ .as_seq()
26118+ .unwrap()
26119+ .iter()
26120+ .map(|v| usize::try_from(v).unwrap())
26121+ .collect::<Vec<usize>>();
26122+ let sum: usize = hists
26123+ .get_item(&Value::from(month))?
26124+ .as_seq()
26125+ .unwrap()
26126+ .iter()
26127+ .map(|v| usize::try_from(v).unwrap())
26128+ .sum();
26129+ let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
26130+ // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
26131+ Ok(minijinja::context! {
26132+ month_name => month!(date.month()),
26133+ month => month,
26134+ month_int => date.month() as usize,
26135+ year => date.year(),
26136+ weeks => cal::calendarize_with_offset(date, 1),
26137+ hist => hist,
26138+ sum,
26139+ })
26140+ }
26141+
26142+ /// `pluralize` filter for [`minijinja`].
26143+ ///
26144+ /// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
26145+ /// length `1`. By default, the plural suffix is 's' and the singular suffix is
26146+ /// empty (''). You can specify a singular suffix as the first argument (or
26147+ /// `None`, for the default). You can specify a plural suffix as the second
26148+ /// argument (or `None`, for the default).
26149+ ///
26150+ /// See the examples for the correct usage.
26151+ ///
26152+ /// # Examples
26153+ ///
26154+ /// ```rust,no_run
26155+ /// # use mailpot_web::pluralize;
26156+ /// # use minijinja::Environment;
26157+ ///
26158+ /// let mut env = Environment::new();
26159+ /// env.add_filter("pluralize", pluralize);
26160+ /// for (num, s) in [
26161+ /// (0, "You have 0 messages."),
26162+ /// (1, "You have 1 message."),
26163+ /// (10, "You have 10 messages."),
26164+ /// ] {
26165+ /// assert_eq!(
26166+ /// &env.render_str(
26167+ /// "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
26168+ /// minijinja::context! {
26169+ /// num_messages => num,
26170+ /// }
26171+ /// )
26172+ /// .unwrap(),
26173+ /// s
26174+ /// );
26175+ /// }
26176+ ///
26177+ /// for (num, s) in [
26178+ /// (0, "You have 0 walruses."),
26179+ /// (1, "You have 1 walrus."),
26180+ /// (10, "You have 10 walruses."),
26181+ /// ] {
26182+ /// assert_eq!(
26183+ /// &env.render_str(
26184+ /// r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
26185+ /// minijinja::context! {
26186+ /// num_walruses => num,
26187+ /// }
26188+ /// )
26189+ /// .unwrap(),
26190+ /// s
26191+ /// );
26192+ /// }
26193+ ///
26194+ /// for (num, s) in [
26195+ /// (0, "You have 0 cherries."),
26196+ /// (1, "You have 1 cherry."),
26197+ /// (10, "You have 10 cherries."),
26198+ /// ] {
26199+ /// assert_eq!(
26200+ /// &env.render_str(
26201+ /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26202+ /// minijinja::context! {
26203+ /// num_cherries => num,
26204+ /// }
26205+ /// )
26206+ /// .unwrap(),
26207+ /// s
26208+ /// );
26209+ /// }
26210+ ///
26211+ /// assert_eq!(
26212+ /// &env.render_str(
26213+ /// r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26214+ /// minijinja::context! {
26215+ /// num_cherries => vec![(); 5],
26216+ /// }
26217+ /// )
26218+ /// .unwrap(),
26219+ /// "You have 5 cherries."
26220+ /// );
26221+ ///
26222+ /// assert_eq!(
26223+ /// &env.render_str(
26224+ /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26225+ /// minijinja::context! {
26226+ /// num_cherries => "5",
26227+ /// }
26228+ /// )
26229+ /// .unwrap(),
26230+ /// "You have 5 cherries."
26231+ /// );
26232+ /// assert_eq!(
26233+ /// &env.render_str(
26234+ /// r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26235+ /// minijinja::context! {
26236+ /// num_cherries => true,
26237+ /// }
26238+ /// )
26239+ /// .unwrap()
26240+ /// .to_string(),
26241+ /// "You have 1 cherry.",
26242+ /// );
26243+ /// assert_eq!(
26244+ /// &env.render_str(
26245+ /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26246+ /// minijinja::context! {
26247+ /// num_cherries => 0.5f32,
26248+ /// }
26249+ /// )
26250+ /// .unwrap_err()
26251+ /// .to_string(),
26252+ /// "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
26253+ /// length but of type number (in <string>:1)",
26254+ /// );
26255+ /// ```
26256+ pub fn pluralize(
26257+ v: Value,
26258+ singular: Option<String>,
26259+ plural: Option<String>,
26260+ ) -> Result<Value, minijinja::Error> {
26261+ macro_rules! int_try_from {
26262+ ($ty:ty) => {
26263+ <$ty>::try_from(v.clone()).ok().map(|v| v != 1)
26264+ };
26265+ ($fty:ty, $($ty:ty),*) => {
26266+ int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
26267+ }
26268+ }
26269+ let is_plural: bool = v
26270+ .as_str()
26271+ .and_then(|s| s.parse::<i128>().ok())
26272+ .map(|l| l != 1)
26273+ .or_else(|| v.len().map(|l| l != 1))
26274+ .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
26275+ .ok_or_else(|| {
26276+ minijinja::Error::new(
26277+ minijinja::ErrorKind::InvalidOperation,
26278+ format!(
26279+ "Pluralize argument is not an integer, or a sequence / object with a length \
26280+ but of type {}",
26281+ v.kind()
26282+ ),
26283+ )
26284+ })?;
26285+ Ok(match (is_plural, singular, plural) {
26286+ (false, None, _) => "".into(),
26287+ (false, Some(suffix), _) => suffix.into(),
26288+ (true, _, None) => "s".into(),
26289+ (true, _, Some(suffix)) => suffix.into(),
26290+ })
26291+ }
26292+
26293+ /// `strip_carets` filter for [`minijinja`].
26294+ ///
26295+ /// Removes `[<>]` from message ids.
26296+ pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
26297+ Ok(Value::from(
26298+ arg.as_str()
26299+ .ok_or_else(|| {
26300+ minijinja::Error::new(
26301+ minijinja::ErrorKind::InvalidOperation,
26302+ format!("argument to strip_carets() is of type {}", arg.kind()),
26303+ )
26304+ })?
26305+ .strip_carets(),
26306+ ))
26307+ }
26308+
26309+ /// `urlize` filter for [`minijinja`].
26310+ ///
26311+ /// Returns a safe string for use in `<a href=..` attributes.
26312+ ///
26313+ /// # Examples
26314+ ///
26315+ /// ```rust,no_run
26316+ /// # use mailpot_web::urlize;
26317+ /// # use minijinja::Environment;
26318+ /// # use minijinja::value::Value;
26319+ ///
26320+ /// let mut env = Environment::new();
26321+ /// env.add_function("urlize", urlize);
26322+ /// env.add_global(
26323+ /// "root_url_prefix",
26324+ /// Value::from_safe_string("/lists/prefix/".to_string()),
26325+ /// );
26326+ /// assert_eq!(
26327+ /// &env.render_str(
26328+ /// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
26329+ /// minijinja::context! {}
26330+ /// )
26331+ /// .unwrap(),
26332+ /// "<a href=\"/lists/prefix/path/index.html\">link</a>",
26333+ /// );
26334+ /// ```
26335+ pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
26336+ let Some(prefix) = state.lookup("root_url_prefix") else {
26337+ return Ok(arg);
26338+ };
26339+ Ok(Value::from_safe_string(format!("{prefix}{arg}")))
26340+ }
26341+
26342+ /// Make an html heading: `h1, h2, h3` etc.
26343+ ///
26344+ /// # Example
26345+ /// ```rust,no_run
26346+ /// use mailpot_web::minijinja_utils::heading;
26347+ /// use minijinja::value::Value;
26348+ ///
26349+ /// assert_eq!(
26350+ /// "<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>",
26351+ /// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
26352+ /// );
26353+ /// assert_eq!(
26354+ /// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
26355+ /// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
26356+ /// );
26357+ /// assert_eq!(
26358+ /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
26359+ /// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
26360+ /// );
26361+ /// assert_eq!(
26362+ /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
26363+ /// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
26364+ /// );
26365+ /// assert_eq!(
26366+ /// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
26367+ /// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
26368+ /// );
26369+ /// ```
26370+ pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
26371+ use convert_case::{Case, Casing};
26372+ macro_rules! test {
26373+ () => {
26374+ |n| *n > 0 && *n < 7
26375+ };
26376+ }
26377+
26378+ macro_rules! int_try_from {
26379+ ($ty:ty) => {
26380+ <$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
26381+ };
26382+ ($fty:ty, $($ty:ty),*) => {
26383+ int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
26384+ }
26385+ }
26386+ let level: u8 = level
26387+ .as_str()
26388+ .and_then(|s| s.parse::<i128>().ok())
26389+ .filter(test! {})
26390+ .map(|n| n as u8)
26391+ .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
26392+ .ok_or_else(|| {
26393+ if matches!(level.kind(), minijinja::value::ValueKind::Number) {
26394+ minijinja::Error::new(
26395+ minijinja::ErrorKind::InvalidOperation,
26396+ "first heading() argument must be an unsigned integer less than 7 and positive",
26397+ )
26398+ } else {
26399+ minijinja::Error::new(
26400+ minijinja::ErrorKind::InvalidOperation,
26401+ format!(
26402+ "first heading() argument is not an integer < 7 but of type {}",
26403+ level.kind()
26404+ ),
26405+ )
26406+ }
26407+ })?;
26408+ let text = text.as_str().ok_or_else(|| {
26409+ minijinja::Error::new(
26410+ minijinja::ErrorKind::InvalidOperation,
26411+ format!(
26412+ "second heading() argument is not a string but of type {}",
26413+ text.kind()
26414+ ),
26415+ )
26416+ })?;
26417+ if let Some(v) = id {
26418+ let kebab = v.as_str().ok_or_else(|| {
26419+ minijinja::Error::new(
26420+ minijinja::ErrorKind::InvalidOperation,
26421+ format!(
26422+ "third heading() argument is not a string but of type {}",
26423+ v.kind()
26424+ ),
26425+ )
26426+ })?;
26427+ Ok(Value::from_safe_string(format!(
26428+ "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
26429+ href=\"#{kebab}\"></a></h{level}>"
26430+ )))
26431+ } else {
26432+ let kebab_v = text.to_case(Case::Kebab);
26433+ let kebab =
26434+ percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
26435+ Ok(Value::from_safe_string(format!(
26436+ "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
26437+ href=\"#{kebab}\"></a></h{level}>"
26438+ )))
26439+ }
26440+ }
26441+
26442+ /// Make an array of topic strings into html badges.
26443+ ///
26444+ /// # Example
26445+ /// ```rust
26446+ /// use mailpot_web::minijinja_utils::topics;
26447+ /// use minijinja::value::Value;
26448+ ///
26449+ /// let v: Value = topics(Value::from_serializable(&vec![
26450+ /// "a".to_string(),
26451+ /// "aab".to_string(),
26452+ /// "aaab".to_string(),
26453+ /// ]))
26454+ /// .unwrap();
26455+ /// assert_eq!(
26456+ /// "<ul class=\"tags\"><li class=\"tag\" style=\"--red:110;--green:120;--blue:180;\"><span \
26457+ /// class=\"tag-name\"><a href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
26458+ /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
26459+ /// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
26460+ /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
26461+ /// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
26462+ /// &v.to_string()
26463+ /// );
26464+ /// ```
26465+ pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
26466+ topics.try_iter()?;
26467+ let topics: Vec<String> = topics
26468+ .try_iter()?
26469+ .map(|v| v.to_string())
26470+ .collect::<Vec<String>>();
26471+ topics_common(&topics)
26472+ }
26473+
26474+ pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
26475+ let mut ul = String::new();
26476+ write!(&mut ul, r#"<ul class="tags">"#)?;
26477+ for topic in topics {
26478+ write!(
26479+ &mut ul,
26480+ r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
26481+ )?;
26482+ write!(&mut ul, "{}", TopicsPath)?;
26483+ write!(&mut ul, r#"?query="#)?;
26484+ write!(
26485+ &mut ul,
26486+ "{}",
26487+ utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
26488+ )?;
26489+ write!(&mut ul, r#"">"#)?;
26490+ write!(&mut ul, "{}", topic)?;
26491+ write!(&mut ul, r#"</a></span></li>"#)?;
26492+ }
26493+ write!(&mut ul, r#"</ul>"#)?;
26494+ Ok(Value::from_safe_string(ul))
26495+ }
26496+
26497+ #[cfg(test)]
26498+ mod tests {
26499+ use super::*;
26500+
26501+ #[test]
26502+ fn test_pluralize() {
26503+ let mut env = Environment::new();
26504+ env.add_filter("pluralize", pluralize);
26505+ for (num, s) in [
26506+ (0, "You have 0 messages."),
26507+ (1, "You have 1 message."),
26508+ (10, "You have 10 messages."),
26509+ ] {
26510+ assert_eq!(
26511+ &env.render_str(
26512+ "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
26513+ minijinja::context! {
26514+ num_messages => num,
26515+ }
26516+ )
26517+ .unwrap(),
26518+ s
26519+ );
26520+ }
26521+
26522+ for (num, s) in [
26523+ (0, "You have 0 walruses."),
26524+ (1, "You have 1 walrus."),
26525+ (10, "You have 10 walruses."),
26526+ ] {
26527+ assert_eq!(
26528+ &env.render_str(
26529+ r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
26530+ minijinja::context! {
26531+ num_walruses => num,
26532+ }
26533+ )
26534+ .unwrap(),
26535+ s
26536+ );
26537+ }
26538+
26539+ for (num, s) in [
26540+ (0, "You have 0 cherries."),
26541+ (1, "You have 1 cherry."),
26542+ (10, "You have 10 cherries."),
26543+ ] {
26544+ assert_eq!(
26545+ &env.render_str(
26546+ r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26547+ minijinja::context! {
26548+ num_cherries => num,
26549+ }
26550+ )
26551+ .unwrap(),
26552+ s
26553+ );
26554+ }
26555+
26556+ assert_eq!(
26557+ &env.render_str(
26558+ r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26559+ minijinja::context! {
26560+ num_cherries => vec![(); 5],
26561+ }
26562+ )
26563+ .unwrap(),
26564+ "You have 5 cherries."
26565+ );
26566+
26567+ assert_eq!(
26568+ &env.render_str(
26569+ r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26570+ minijinja::context! {
26571+ num_cherries => "5",
26572+ }
26573+ )
26574+ .unwrap(),
26575+ "You have 5 cherries."
26576+ );
26577+ assert_eq!(
26578+ &env.render_str(
26579+ r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26580+ minijinja::context! {
26581+ num_cherries => true,
26582+ }
26583+ )
26584+ .unwrap(),
26585+ "You have 1 cherry.",
26586+ );
26587+ assert_eq!(
26588+ &env.render_str(
26589+ r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
26590+ minijinja::context! {
26591+ num_cherries => 0.5f32,
26592+ }
26593+ )
26594+ .unwrap_err()
26595+ .to_string(),
26596+ "invalid operation: Pluralize argument is not an integer, or a sequence / object with \
26597+ a length but of type number (in <string>:1)",
26598+ );
26599+ }
26600+
26601+ #[test]
26602+ fn test_urlize() {
26603+ let mut env = Environment::new();
26604+ env.add_function("urlize", urlize);
26605+ env.add_global(
26606+ "root_url_prefix",
26607+ Value::from_safe_string("/lists/prefix/".to_string()),
26608+ );
26609+ assert_eq!(
26610+ &env.render_str(
26611+ "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
26612+ minijinja::context! {}
26613+ )
26614+ .unwrap(),
26615+ "<a href=\"/lists/prefix/path/index.html\">link</a>",
26616+ );
26617+ }
26618+
26619+ #[test]
26620+ fn test_heading() {
26621+ assert_eq!(
26622+ "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a \
26623+ class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
26624+ &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None)
26625+ .unwrap()
26626+ .to_string()
26627+ );
26628+ assert_eq!(
26629+ "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" \
26630+ href=\"#short\"></a></h2>",
26631+ &heading(
26632+ 2.into(),
26633+ "bl bfa B AH bAsdb hadas d".into(),
26634+ Some("short".into())
26635+ )
26636+ .unwrap()
26637+ .to_string()
26638+ );
26639+ assert_eq!(
26640+ r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
26641+ &heading(
26642+ 0.into(),
26643+ "bl bfa B AH bAsdb hadas d".into(),
26644+ Some("short".into())
26645+ )
26646+ .unwrap_err()
26647+ .to_string()
26648+ );
26649+ assert_eq!(
26650+ r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
26651+ &heading(
26652+ 8.into(),
26653+ "bl bfa B AH bAsdb hadas d".into(),
26654+ Some("short".into())
26655+ )
26656+ .unwrap_err()
26657+ .to_string()
26658+ );
26659+ assert_eq!(
26660+ r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
26661+ &heading(
26662+ Value::from(vec![Value::from(1)]),
26663+ "bl bfa B AH bAsdb hadas d".into(),
26664+ Some("short".into())
26665+ )
26666+ .unwrap_err()
26667+ .to_string()
26668+ );
26669+ }
26670+
26671+ #[test]
26672+ fn test_strip_carets() {
26673+ let mut env = Environment::new();
26674+ env.add_filter("strip_carets", strip_carets);
26675+ assert_eq!(
26676+ &env.render_str(
26677+ "{{ msg_id | strip_carets }}",
26678+ minijinja::context! {
26679+ msg_id => "<hello1@example.com>",
26680+ }
26681+ )
26682+ .unwrap(),
26683+ "hello1@example.com",
26684+ );
26685+ }
26686+
26687+ #[test]
26688+ fn test_calendarize() {
26689+ use std::collections::HashMap;
26690+
26691+ let mut env = Environment::new();
26692+ env.add_function("calendarize", calendarize);
26693+
26694+ let month = "2001-09";
26695+ let mut hist = [0usize; 31];
26696+ hist[15] = 5;
26697+ hist[1] = 1;
26698+ hist[0] = 512;
26699+ hist[30] = 30;
26700+ assert_eq!(
26701+ &env.render_str(
26702+ "{% set c=calendarize(month, hists) %}Month: {{ c.month }} Month Name: {{ \
26703+ c.month_name }} Month Int: {{ c.month_int }} Year: {{ c.year }} Sum: {{ c.sum }} {% \
26704+ for week in c.weeks %}{% for day in week %}{% set num = c.hist[day-1] %}({{ day }}, \
26705+ {{ num }}){% endfor %}{% endfor %}",
26706+ minijinja::context! {
26707+ month,
26708+ hists => vec![(month.to_string(), hist)].into_iter().collect::<HashMap<String, [usize;
26709+ 31]>>(),
26710+ }
26711+ )
26712+ .unwrap(),
26713+ "Month: 2001-09 Month Name: September Month Int: 9 Year: 2001 Sum: 548 (0, 30)(0, 30)(0, \
26714+ 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, \
26715+ 0)(12, 0)(13, 0)(14, 0)(15, 0)(16, 5)(17, 0)(18, 0)(19, 0)(20, 0)(21, 0)(22, 0)(23, \
26716+ 0)(24, 0)(25, 0)(26, 0)(27, 0)(28, 0)(29, 0)(30, 0)"
26717+ );
26718+ }
26719+
26720+ #[test]
26721+ fn test_list_html_safe() {
26722+ let mut list = MailingList {
26723+ pk: 0,
26724+ name: String::new(),
26725+ id: String::new(),
26726+ address: String::new(),
26727+ description: None,
26728+ topics: vec![],
26729+ archive_url: None,
26730+ inner: DbVal(
26731+ mailpot::models::MailingList {
26732+ pk: 0,
26733+ name: String::new(),
26734+ id: String::new(),
26735+ address: String::new(),
26736+ description: None,
26737+ topics: vec![],
26738+ archive_url: None,
26739+ },
26740+ 0,
26741+ ),
26742+ is_description_html_safe: false,
26743+ };
26744+
26745+ let mut list_owners = vec![ListOwner {
26746+ pk: 0,
26747+ list: 0,
26748+ address: "admin@example.com".to_string(),
26749+ name: None,
26750+ }];
26751+ let administrators = vec!["admin@example.com".to_string()];
26752+ list.set_safety(&list_owners, &administrators);
26753+ assert!(list.is_description_html_safe);
26754+ list.set_safety::<ListOwner>(&[], &[]);
26755+ assert!(list.is_description_html_safe);
26756+ list.is_description_html_safe = false;
26757+ list_owners[0].address = "user@example.com".to_string();
26758+ list.set_safety(&list_owners, &administrators);
26759+ assert!(!list.is_description_html_safe);
26760+ }
26761+ }
26762 diff --git a/mailpot-web/src/minijinja_utils/compressed.rs b/mailpot-web/src/minijinja_utils/compressed.rs
26763new file mode 100644
26764index 0000000..8965d02
26765--- /dev/null
26766+++ b/mailpot-web/src/minijinja_utils/compressed.rs
26767 @@ -0,0 +1,20 @@
26768+ /*
26769+ * This file is part of mailpot
26770+ *
26771+ * Copyright 2020 - Manos Pitsidianakis
26772+ *
26773+ * This program is free software: you can redistribute it and/or modify
26774+ * it under the terms of the GNU Affero General Public License as
26775+ * published by the Free Software Foundation, either version 3 of the
26776+ * License, or (at your option) any later version.
26777+ *
26778+ * This program is distributed in the hope that it will be useful,
26779+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
26780+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
26781+ * GNU Affero General Public License for more details.
26782+ *
26783+ * You should have received a copy of the GNU Affero General Public License
26784+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
26785+ */
26786+
26787+ pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data");
26788 diff --git a/mailpot-web/src/settings.rs b/mailpot-web/src/settings.rs
26789new file mode 100644
26790index 0000000..13a6736
26791--- /dev/null
26792+++ b/mailpot-web/src/settings.rs
26793 @@ -0,0 +1,411 @@
26794+ /*
26795+ * This file is part of mailpot
26796+ *
26797+ * Copyright 2020 - Manos Pitsidianakis
26798+ *
26799+ * This program is free software: you can redistribute it and/or modify
26800+ * it under the terms of the GNU Affero General Public License as
26801+ * published by the Free Software Foundation, either version 3 of the
26802+ * License, or (at your option) any later version.
26803+ *
26804+ * This program is distributed in the hope that it will be useful,
26805+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
26806+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
26807+ * GNU Affero General Public License for more details.
26808+ *
26809+ * You should have received a copy of the GNU Affero General Public License
26810+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
26811+ */
26812+
26813+ use mailpot::models::{
26814+ changesets::{AccountChangeset, ListSubscriptionChangeset},
26815+ ListSubscription,
26816+ };
26817+
26818+ use super::*;
26819+
26820+ pub async fn settings(
26821+ _: SettingsPath,
26822+ mut session: WritableSession,
26823+ Extension(user): Extension<User>,
26824+ state: Arc<AppState>,
26825+ ) -> Result<Html<String>, ResponseError> {
26826+ let crumbs = vec![
26827+ Crumb {
26828+ label: "Home".into(),
26829+ url: "/".into(),
26830+ },
26831+ Crumb {
26832+ label: "Settings".into(),
26833+ url: SettingsPath.to_crumb(),
26834+ },
26835+ ];
26836+ let db = Connection::open_db(state.conf.clone())?;
26837+ let acc = db
26838+ .account_by_address(&user.address)
26839+ .with_status(StatusCode::BAD_REQUEST)?
26840+ .ok_or_else(|| {
26841+ ResponseError::new("Account not found".to_string(), StatusCode::BAD_REQUEST)
26842+ })?;
26843+ let subscriptions = db
26844+ .account_subscriptions(acc.pk())
26845+ .with_status(StatusCode::BAD_REQUEST)?
26846+ .into_iter()
26847+ .filter_map(|s| match db.list(s.list) {
26848+ Err(err) => Some(Err(err)),
26849+ Ok(Some(list)) => Some(Ok((s, list))),
26850+ Ok(None) => None,
26851+ })
26852+ .collect::<Result<
26853+ Vec<(
26854+ DbVal<mailpot::models::ListSubscription>,
26855+ DbVal<mailpot::models::MailingList>,
26856+ )>,
26857+ mailpot::Error,
26858+ >>()?;
26859+
26860+ let context = minijinja::context! {
26861+ page_title => "Account settings",
26862+ user => user,
26863+ subscriptions => subscriptions,
26864+ current_user => user,
26865+ messages => session.drain_messages(),
26866+ crumbs => crumbs,
26867+ };
26868+ Ok(Html(
26869+ TEMPLATES.get_template("settings.html")?.render(context)?,
26870+ ))
26871+ }
26872+
26873+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
26874+ #[serde(tag = "type", rename_all = "kebab-case")]
26875+ pub enum ChangeSetting {
26876+ Subscribe { list_pk: IntPOST },
26877+ Unsubscribe { list_pk: IntPOST },
26878+ ChangePassword { new: String },
26879+ ChangePublicKey { new: String },
26880+ // RemovePassword,
26881+ RemovePublicKey,
26882+ ChangeName { new: String },
26883+ }
26884+
26885+ #[allow(non_snake_case)]
26886+ pub async fn settings_POST(
26887+ _: SettingsPath,
26888+ mut session: WritableSession,
26889+ Extension(user): Extension<User>,
26890+ Form(payload): Form<ChangeSetting>,
26891+ state: Arc<AppState>,
26892+ ) -> Result<Redirect, ResponseError> {
26893+ let db = Connection::open_db(state.conf.clone())?;
26894+ let acc = db
26895+ .account_by_address(&user.address)
26896+ .with_status(StatusCode::BAD_REQUEST)?
26897+ .ok_or_else(|| {
26898+ ResponseError::new("Account not found".to_string(), StatusCode::BAD_REQUEST)
26899+ })?;
26900+
26901+ match payload {
26902+ ChangeSetting::Subscribe {
26903+ list_pk: IntPOST(list_pk),
26904+ } => {
26905+ let subscriptions = db
26906+ .account_subscriptions(acc.pk())
26907+ .with_status(StatusCode::BAD_REQUEST)?;
26908+ if subscriptions.iter().any(|s| s.list == list_pk) {
26909+ session.add_message(Message {
26910+ message: "You are already subscribed to this list.".into(),
26911+ level: Level::Info,
26912+ })?;
26913+ } else {
26914+ db.add_subscription(
26915+ list_pk,
26916+ ListSubscription {
26917+ pk: 0,
26918+ list: list_pk,
26919+ account: Some(acc.pk()),
26920+ address: acc.address.clone(),
26921+ name: acc.name.clone(),
26922+ digest: false,
26923+ enabled: true,
26924+ verified: true,
26925+ hide_address: false,
26926+ receive_duplicates: false,
26927+ receive_own_posts: false,
26928+ receive_confirmation: false,
26929+ },
26930+ )?;
26931+ session.add_message(Message {
26932+ message: "You have subscribed to this list.".into(),
26933+ level: Level::Success,
26934+ })?;
26935+ }
26936+ }
26937+ ChangeSetting::Unsubscribe {
26938+ list_pk: IntPOST(list_pk),
26939+ } => {
26940+ let subscriptions = db
26941+ .account_subscriptions(acc.pk())
26942+ .with_status(StatusCode::BAD_REQUEST)?;
26943+ if !subscriptions.iter().any(|s| s.list == list_pk) {
26944+ session.add_message(Message {
26945+ message: "You are already not subscribed to this list.".into(),
26946+ level: Level::Info,
26947+ })?;
26948+ } else {
26949+ let db = db.trusted();
26950+ db.remove_subscription(list_pk, &acc.address)?;
26951+ session.add_message(Message {
26952+ message: "You have unsubscribed from this list.".into(),
26953+ level: Level::Success,
26954+ })?;
26955+ }
26956+ }
26957+ ChangeSetting::ChangePassword { new } => {
26958+ db.update_account(AccountChangeset {
26959+ address: acc.address.clone(),
26960+ name: None,
26961+ public_key: None,
26962+ password: Some(new.clone()),
26963+ enabled: None,
26964+ })
26965+ .with_status(StatusCode::BAD_REQUEST)?;
26966+ session.add_message(Message {
26967+ message: "You have successfully updated your SSH public key.".into(),
26968+ level: Level::Success,
26969+ })?;
26970+ let mut user = user.clone();
26971+ user.password = new;
26972+ state.insert_user(acc.pk(), user).await;
26973+ }
26974+ ChangeSetting::ChangePublicKey { new } => {
26975+ db.update_account(AccountChangeset {
26976+ address: acc.address.clone(),
26977+ name: None,
26978+ public_key: Some(Some(new.clone())),
26979+ password: None,
26980+ enabled: None,
26981+ })
26982+ .with_status(StatusCode::BAD_REQUEST)?;
26983+ session.add_message(Message {
26984+ message: "You have successfully updated your PGP public key.".into(),
26985+ level: Level::Success,
26986+ })?;
26987+ let mut user = user.clone();
26988+ user.public_key = Some(new);
26989+ state.insert_user(acc.pk(), user).await;
26990+ }
26991+ ChangeSetting::RemovePublicKey => {
26992+ db.update_account(AccountChangeset {
26993+ address: acc.address.clone(),
26994+ name: None,
26995+ public_key: Some(None),
26996+ password: None,
26997+ enabled: None,
26998+ })
26999+ .with_status(StatusCode::BAD_REQUEST)?;
27000+ session.add_message(Message {
27001+ message: "You have successfully removed your PGP public key.".into(),
27002+ level: Level::Success,
27003+ })?;
27004+ let mut user = user.clone();
27005+ user.public_key = None;
27006+ state.insert_user(acc.pk(), user).await;
27007+ }
27008+ ChangeSetting::ChangeName { new } => {
27009+ let new = if new.trim().is_empty() {
27010+ None
27011+ } else {
27012+ Some(new)
27013+ };
27014+ db.update_account(AccountChangeset {
27015+ address: acc.address.clone(),
27016+ name: Some(new.clone()),
27017+ public_key: None,
27018+ password: None,
27019+ enabled: None,
27020+ })
27021+ .with_status(StatusCode::BAD_REQUEST)?;
27022+ session.add_message(Message {
27023+ message: "You have successfully updated your name.".into(),
27024+ level: Level::Success,
27025+ })?;
27026+ let mut user = user.clone();
27027+ user.name = new.clone();
27028+ state.insert_user(acc.pk(), user).await;
27029+ }
27030+ }
27031+
27032+ Ok(Redirect::to(&format!(
27033+ "{}{}",
27034+ &state.root_url_prefix,
27035+ SettingsPath.to_uri()
27036+ )))
27037+ }
27038+
27039+ pub async fn user_list_subscription(
27040+ ListSettingsPath(id): ListSettingsPath,
27041+ mut session: WritableSession,
27042+ Extension(user): Extension<User>,
27043+ State(state): State<Arc<AppState>>,
27044+ ) -> Result<Html<String>, ResponseError> {
27045+ let db = Connection::open_db(state.conf.clone())?;
27046+ let Some(list) = (match id {
27047+ ListPathIdentifier::Pk(id) => db.list(id)?,
27048+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
27049+ }) else {
27050+ return Err(ResponseError::new(
27051+ "List not found".to_string(),
27052+ StatusCode::NOT_FOUND,
27053+ ));
27054+ };
27055+ let acc = match db.account_by_address(&user.address)? {
27056+ Some(v) => v,
27057+ None => {
27058+ return Err(ResponseError::new(
27059+ "Account not found".to_string(),
27060+ StatusCode::BAD_REQUEST,
27061+ ))
27062+ }
27063+ };
27064+ let mut subscriptions = db
27065+ .account_subscriptions(acc.pk())
27066+ .with_status(StatusCode::BAD_REQUEST)?;
27067+ subscriptions.retain(|s| s.list == list.pk());
27068+ let subscription = db
27069+ .list_subscription(
27070+ list.pk(),
27071+ subscriptions
27072+ .first()
27073+ .ok_or_else(|| {
27074+ ResponseError::new(
27075+ "Subscription not found".to_string(),
27076+ StatusCode::BAD_REQUEST,
27077+ )
27078+ })?
27079+ .pk(),
27080+ )
27081+ .with_status(StatusCode::BAD_REQUEST)?;
27082+
27083+ let crumbs = vec![
27084+ Crumb {
27085+ label: "Home".into(),
27086+ url: "/".into(),
27087+ },
27088+ Crumb {
27089+ label: "Settings".into(),
27090+ url: SettingsPath.to_crumb(),
27091+ },
27092+ Crumb {
27093+ label: "List Subscription".into(),
27094+ url: ListSettingsPath(list.pk().into()).to_crumb(),
27095+ },
27096+ ];
27097+
27098+ let list_owners = db.list_owners(list.pk)?;
27099+ let mut list = crate::minijinja_utils::MailingList::from(list);
27100+ list.set_safety(list_owners.as_slice(), &state.conf.administrators);
27101+ let context = minijinja::context! {
27102+ page_title => "Subscription settings",
27103+ user => user,
27104+ list => list,
27105+ subscription => subscription,
27106+ current_user => user,
27107+ messages => session.drain_messages(),
27108+ crumbs => crumbs,
27109+ };
27110+ Ok(Html(
27111+ TEMPLATES
27112+ .get_template("settings_subscription.html")?
27113+ .render(context)?,
27114+ ))
27115+ }
27116+
27117+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
27118+ pub struct SubscriptionFormPayload {
27119+ #[serde(default)]
27120+ pub digest: bool,
27121+ #[serde(default)]
27122+ pub hide_address: bool,
27123+ #[serde(default)]
27124+ pub receive_duplicates: bool,
27125+ #[serde(default)]
27126+ pub receive_own_posts: bool,
27127+ #[serde(default)]
27128+ pub receive_confirmation: bool,
27129+ }
27130+
27131+ #[allow(non_snake_case)]
27132+ pub async fn user_list_subscription_POST(
27133+ ListSettingsPath(id): ListSettingsPath,
27134+ mut session: WritableSession,
27135+ Extension(user): Extension<User>,
27136+ Form(payload): Form<SubscriptionFormPayload>,
27137+ state: Arc<AppState>,
27138+ ) -> Result<Redirect, ResponseError> {
27139+ let db = Connection::open_db(state.conf.clone())?;
27140+
27141+ let Some(list) = (match id {
27142+ ListPathIdentifier::Pk(id) => db.list(id)?,
27143+ ListPathIdentifier::Id(id) => db.list_by_id(id)?,
27144+ }) else {
27145+ return Err(ResponseError::new(
27146+ "List not found".to_string(),
27147+ StatusCode::NOT_FOUND,
27148+ ));
27149+ };
27150+
27151+ let acc = match db.account_by_address(&user.address)? {
27152+ Some(v) => v,
27153+ None => {
27154+ return Err(ResponseError::new(
27155+ "Account with this address was not found".to_string(),
27156+ StatusCode::BAD_REQUEST,
27157+ ));
27158+ }
27159+ };
27160+ let mut subscriptions = db
27161+ .account_subscriptions(acc.pk())
27162+ .with_status(StatusCode::BAD_REQUEST)?;
27163+
27164+ subscriptions.retain(|s| s.list == list.pk());
27165+ let mut s = db
27166+ .list_subscription(list.pk(), subscriptions[0].pk())
27167+ .with_status(StatusCode::BAD_REQUEST)?;
27168+
27169+ let SubscriptionFormPayload {
27170+ digest,
27171+ hide_address,
27172+ receive_duplicates,
27173+ receive_own_posts,
27174+ receive_confirmation,
27175+ } = payload;
27176+
27177+ let cset = ListSubscriptionChangeset {
27178+ list: s.list,
27179+ address: std::mem::take(&mut s.address),
27180+ account: None,
27181+ name: None,
27182+ digest: Some(digest),
27183+ hide_address: Some(hide_address),
27184+ receive_duplicates: Some(receive_duplicates),
27185+ receive_own_posts: Some(receive_own_posts),
27186+ receive_confirmation: Some(receive_confirmation),
27187+ enabled: None,
27188+ verified: None,
27189+ };
27190+
27191+ db.update_subscription(cset)
27192+ .with_status(StatusCode::BAD_REQUEST)?;
27193+
27194+ session.add_message(Message {
27195+ message: "Settings saved successfully.".into(),
27196+ level: Level::Success,
27197+ })?;
27198+
27199+ Ok(Redirect::to(&format!(
27200+ "{}{}",
27201+ &state.root_url_prefix,
27202+ ListSettingsPath(list.id.clone().into()).to_uri()
27203+ )))
27204+ }
27205 diff --git a/mailpot-web/src/templates/auth.html b/mailpot-web/src/templates/auth.html
27206new file mode 100644
27207index 0000000..570c38e
27208--- /dev/null
27209+++ b/mailpot-web/src/templates/auth.html
27210 @@ -0,0 +1,15 @@
27211+ {% include "header.html" %}
27212+ <div class="body body-grid">
27213+ <p aria-label="instructions">Sign <mark class="ssh-challenge-token" title="challenge token">{{ ssh_challenge }}</mark> with your previously configured key within <time title="{{ timeout_left }} minutes left" datetime="{{ timeout_left }}">{{ timeout_left }} minutes</time>. Example:</p>
27214+ <pre class="command-line-example" title="example terminal command for UNIX shells that signs the challenge token with a public SSH key" >printf <ruby>'<mark>{{ ssh_challenge }}</mark>'<rp>(</rp><rt>signin challenge</rt><rp>)</rp></ruby> | ssh-keygen -Y sign -f <ruby>~/.ssh/id_rsa <rp>(</rp><rt>your account's key</rt><rp>)</rp></ruby> -n <ruby>{{ namespace }}<rp>(</rp><rt>namespace</rt><rp>)</rp></ruby></pre>
27215+ <form method="post" class="login-form login-ssh" aria-label="login form">
27216+ <label for="id_address" id="id_address_label">Email address:</label>
27217+ <input type="text" name="address" required="" id="id_address" aria-labelledby="id_address_label">
27218+ <label for="id_password">SSH signature:</label>
27219+ <textarea class="key-or-sig-input" name="password" cols="15" rows="5" placeholder="-----BEGIN SSH SIGNATURE-----&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;chang=&#10;-----END SSH SIGNATURE-----&#10;" required="" id="id_password"></textarea>
27220+ <input type="submit" value="login">
27221+ <input type="hidden" name="next" value="">
27222+ <!--<input formaction="" formnovalidate="true" type="submit" name="refresh" value="refresh token"-->
27223+ </form>
27224+ </div>
27225+ {% include "footer.html" %}
27226 diff --git a/mailpot-web/src/templates/calendar.html b/mailpot-web/src/templates/calendar.html
27227new file mode 100644
27228index 0000000..8eccf8f
27229--- /dev/null
27230+++ b/mailpot-web/src/templates/calendar.html
27231 @@ -0,0 +1,43 @@
27232+ {% macro cal(date, hists) %}
27233+ {% set c=calendarize(date, hists) %}
27234+ {% if c.sum > 0 %}
27235+ <table>
27236+ <caption align="top">
27237+ <!--<a href="{{ root_url_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
27238+ <a href="#" style="color: GrayText;">
27239+ {{ c.month_name }} {{ c.year }}
27240+ </a>
27241+ </caption>
27242+ <thead>
27243+ <tr>
27244+ <th>M</th>
27245+ <th>Tu</th>
27246+ <th>W</th>
27247+ <th>Th</th>
27248+ <th>F</th>
27249+ <th>Sa</th>
27250+ <th>Su</th>
27251+ </tr>
27252+ </thead>
27253+ <tbody>
27254+ {% for week in c.weeks %}
27255+ <tr>
27256+ {% for day in week %}
27257+ {% if day == 0 %}
27258+ <td></td>
27259+ {% else %}
27260+ {% set num = c.hist[day-1] %}
27261+ {% if num > 0 %}
27262+ <td><ruby>{{ day }}<rt>({{ num }})</rt></ruby></td>
27263+ {% else %}
27264+ <td class="empty">{{ day }}</td>
27265+ {% endif %}
27266+ {% endif %}
27267+ {% endfor %}
27268+ </tr>
27269+ {% endfor %}
27270+ </tbody>
27271+ </table>
27272+ {% endif %}
27273+ {% endmacro %}
27274+ {% set alias = cal %}
27275 diff --git a/mailpot-web/src/templates/css.html b/mailpot-web/src/templates/css.html
27276new file mode 100644
27277index 0000000..f644210
27278--- /dev/null
27279+++ b/mailpot-web/src/templates/css.html
27280 @@ -0,0 +1,1092 @@
27281+ <style>@charset "UTF-8";
27282+ /* Use a more intuitive box-sizing model */
27283+ *, *::before, *::after {
27284+ box-sizing: border-box;
27285+ }
27286+
27287+ /* Remove all margins & padding */
27288+ * {
27289+ margin: 0;
27290+ padding: 0;
27291+ word-wrap: break-word;
27292+ }
27293+
27294+ /* Only show focus outline when the user is tabbing (not when clicking) */
27295+ *:focus {
27296+ outline: none;
27297+ }
27298+
27299+ *:focus-visible {
27300+ outline: 1px solid blue;
27301+ }
27302+
27303+ /* Prevent mobile browsers increasing font-size */
27304+ html {
27305+ -moz-text-size-adjust: none;
27306+ -webkit-text-size-adjust: none;
27307+ text-size-adjust: none;
27308+ font-family:-apple-system,BlinkMacSystemFont,Arial,sans-serif;
27309+ line-height:1.15;
27310+ -webkit-text-size-adjust:100%;
27311+ overflow-y:scroll;
27312+ }
27313+
27314+ /* Allow percentage-based heights */
27315+ /* Setting width: 100% isn't required because it is a default for block-level elements (html & body are block level) */
27316+ html, body {
27317+ height: 100%;
27318+ }
27319+
27320+ body {
27321+ /* Prevent the rubber band effect when the user scrolls to the top or bottom of the page (WebKit only) */
27322+ overscroll-behavior: none;
27323+
27324+ /* Prevent the browser from synthesizing missing typefaces */
27325+ font-synthesis: none;
27326+
27327+ margin:0;
27328+ font-feature-settings:"onum" 1;
27329+ text-rendering:optimizeLegibility;
27330+ -webkit-font-smoothing:antialiased;
27331+ -moz-osx-font-smoothing:grayscale;
27332+ font-family:var(--sans-serif-system-stack);
27333+ font-size:100%;
27334+ }
27335+
27336+ /* Remove unintuitive behaviour such as gaps around media elements. */
27337+ img, picture, video, canvas, svg, iframe {
27338+ display: block;
27339+ }
27340+
27341+ /* Avoid text overflow */
27342+ h1, h2, h3, h4, h5, h6, p, strong {
27343+ overflow-wrap: break-word;
27344+ }
27345+
27346+ p {
27347+ line-height: 1.4;
27348+ }
27349+
27350+ h1,
27351+ h2,
27352+ h3,
27353+ h4,
27354+ h5,
27355+ h6 {
27356+ position: relative;
27357+ }
27358+ h1 > a.self-link,
27359+ h2 > a.self-link,
27360+ h3 > a.self-link,
27361+ h4 > a.self-link,
27362+ h5 > a.self-link,
27363+ h6 > a.self-link {
27364+ font-size: 83%;
27365+ }
27366+
27367+ a.self-link::before {
27368+ content: "§";
27369+ /* increase surface area for clicks */
27370+ padding: 1rem;
27371+ margin: -1rem;
27372+ }
27373+
27374+ a.self-link {
27375+ --width: 22px;
27376+ position: absolute;
27377+ top: 0px;
27378+ left: calc(var(--width) - 3.5rem);
27379+ width: calc(-1 * var(--width) + 3.5rem);
27380+ height: 2em;
27381+ text-align: center;
27382+ border: medium none;
27383+ transition: opacity 0.2s ease 0s;
27384+ opacity: 0.5;
27385+ }
27386+
27387+ a {
27388+ text-decoration: none;
27389+ }
27390+
27391+ a[href]:focus, a[href]:hover {
27392+ text-decoration-thickness: 2px;
27393+ text-decoration-skip-ink: none;
27394+ }
27395+
27396+ a[href] {
27397+ text-decoration: underline;
27398+ color: #034575;
27399+ color: var(--a-normal-text);
27400+ text-decoration-color: #707070;
27401+ text-decoration-color: var(--accent-secondary);
27402+ text-decoration-skip-ink: none;
27403+ }
27404+
27405+ ul, ol {
27406+ list-style: none;
27407+ }
27408+
27409+ code {
27410+ font-family: var(--monospace-system-stack);
27411+ overflow-wrap: anywhere;
27412+ }
27413+
27414+ pre {
27415+ font-family: var(--monospace-system-stack);
27416+ }
27417+
27418+ input {
27419+ border: none;
27420+ }
27421+
27422+ input, button, textarea, select {
27423+ font: inherit;
27424+ }
27425+
27426+ /* Create a root stacking context (only when using frameworks like Next.js) */
27427+ #__next {
27428+ isolation: isolate;
27429+ }
27430+
27431+ :root {
27432+ --emoji-system-stack: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
27433+ --monospace-system-stack: /* apple */ ui-monospace, SFMono-Regular, Menlo, Monaco,
27434+ /* windows */ "Cascadia Mono", "Segoe UI Mono", Consolas,
27435+ /* free unixes */ "DejaVu Sans Mono", "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace, var(--emoji-system-stack);
27436+ --sans-serif-system-stack:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif, var(--emoji-system-stack);
27437+ --grotesque-system-stack: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif, var(--emoji-system-stack);
27438+ --text-primary: CanvasText;
27439+ --text-faded: GrayText;
27440+ --horizontal-rule: #88929d;
27441+ --code-foreground: #124;
27442+ --code-background: #8fbcbb;
27443+ --a-visited-text: var(--a-normal-text);
27444+ --tag-border-color: black;
27445+ }
27446+
27447+ @media (prefers-color-scheme: light) {
27448+ :root {
27449+ --text-secondary: #4e4e4e;
27450+ --text-inactive: #9e9e9ea6;
27451+ --text-link: #0069c2;
27452+ --text-invert: #fff;
27453+ --background-primary: #fff;
27454+ --background-secondary: #ebebeb;
27455+ --background-tertiary: #fff;
27456+ --background-toc-active: #ebeaea;
27457+ --background-mark-yellow: #c7b70066;
27458+ --background-mark-green: #00d06166;
27459+ --background-information: #0085f21a;
27460+ --background-warning: #ff2a511a;
27461+ --background-critical: #d300381a;
27462+ --background-success: #0079361a;
27463+ --border-primary: #cdcdcd;
27464+ --border-secondary: #cdcdcd;
27465+ --button-primary-default: #1b1b1b;
27466+ --button-primary-hover: #696969;
27467+ --button-primary-active: #9e9e9e;
27468+ --button-primary-inactive: #1b1b1b;
27469+ --button-secondary-default: #fff;
27470+ --button-secondary-hover: #cdcdcd;
27471+ --button-secondary-active: #cdcdcd;
27472+ --button-secondary-inactive: #f9f9fb;
27473+ --button-secondary-border-focus: #0085f2;
27474+ --button-secondary-border-red: #ff97a0;
27475+ --button-secondary-border-red-focus: #ffd9dc;
27476+ --icon-primary: #696969;
27477+ --icon-secondary: #b3b3b3;
27478+ --icon-information: #0085f2;
27479+ --icon-warning: #ff2a51;
27480+ --icon-critical: #d30038;
27481+ --icon-success: #007936;
27482+ --accent-primary: #0085f2;
27483+ --accent-primary-engage: #0085f21a;
27484+ --accent-secondary: #0085f2;
27485+ --accent-tertiary: #0085f21a;
27486+ --shadow-01: 0 1px 2px rgba(43,42,51,.05);
27487+ --shadow-02: 0 1px 6px rgba(43,42,51,.1);
27488+ --focus-01: 0 0 0 3px rgba(0,144,237,.4);
27489+ --field-focus-border: #0085f2;
27490+ --code-token-tag: #0069c2;
27491+ --code-token-punctuation: #858585;
27492+ --code-token-attribute-name: #d30038;
27493+ --code-token-attribute-value: #007936;
27494+ --code-token-comment: #858585;
27495+ --code-token-default: #1b1b1b;
27496+ --code-token-selector: #872bff;
27497+ --code-background-inline: #f2f1f1;
27498+ --code-background-block: #f2f1f1;
27499+ --notecard-link-color: #343434;
27500+ --scrollbar-bg: transparent;
27501+ --scrollbar-color: #00000040;
27502+ --category-color: #0085f2;
27503+ --category-color-background: #0085f210;
27504+ --code-color: #5e9eff;
27505+ --mark-color: #dce2f2;
27506+ --blend-color: #fff80;
27507+ --text-primary-red: #d30038;
27508+ --text-primary-green: #007936;
27509+ --text-primary-blue: #0069c2;
27510+ --text-primary-yellow: #746a00;
27511+ --form-invalid-color: #d30038;
27512+ --form-invalid-focus-color: #ff2a51;
27513+ --form-invalid-focus-effect-color: #ff2a5133;
27514+
27515+ --a-normal-text: #034575;
27516+ --a-normal-underline: #bbb;
27517+ --a-visited-underline: #707070;
27518+ --a-hover-bg: #bfbfbf40;
27519+ --a-active-text: #c00;
27520+ --a-active-underline: #c00;
27521+ --tag-border-color: #0000005e;
27522+ color-scheme: light;
27523+ }
27524+ }
27525+
27526+ @media (prefers-color-scheme: dark) {
27527+ :root {
27528+ --text-secondary: #cdcdcd;
27529+ --text-inactive: #cdcdcda6;
27530+ --text-link: #8cb4ff;
27531+ --text-invert: #1b1b1b;
27532+ --background-primary: #1b1b1b;
27533+ --background-secondary: #343434;
27534+ --background-tertiary: #4e4e4e;
27535+ --background-toc-active: #343434;
27536+ --background-mark-yellow: #c7b70066;
27537+ --background-mark-green: #00d06166;
27538+ --background-information: #0085f21a;
27539+ --background-warning: #ff2a511a;
27540+ --background-critical: #d300381a;
27541+ --background-success: #0079361a;
27542+ --border-primary: #858585;
27543+ --border-secondary: #696969;
27544+ --button-primary-default: #fff;
27545+ --button-primary-hover: #cdcdcd;
27546+ --button-primary-active: #9e9e9e;
27547+ --button-primary-inactive: #fff;
27548+ --button-secondary-default: #4e4e4e;
27549+ --button-secondary-hover: #858585;
27550+ --button-secondary-active: #9e9e9e;
27551+ --button-secondary-inactive: #4e4e4e;
27552+ --button-secondary-border-focus: #0085f2;
27553+ --button-secondary-border-red: #ff97a0;
27554+ --button-secondary-border-red-focus: #ffd9dc;
27555+ --icon-primary: #fff;
27556+ --icon-secondary: #b3b3b3;
27557+ --icon-information: #5e9eff;
27558+ --icon-warning: #afa100;
27559+ --icon-critical: #ff707f;
27560+ --icon-success: #00b755;
27561+ --accent-primary: #5e9eff;
27562+ --accent-primary-engage: #5e9eff1a;
27563+ --accent-secondary: #5e9eff;
27564+ --accent-tertiary: #0085f21a;
27565+ --shadow-01: 0 1px 2px rgba(251,251,254,.2);
27566+ --shadow-02: 0 1px 6px rgba(251,251,254,.2);
27567+ --focus-01: 0 0 0 3px rgba(251,251,254,.5);
27568+ --field-focus-border: #fff;
27569+ --notecard-link-color: #e2e2e2;
27570+ --scrollbar-bg: transparent;
27571+ --scrollbar-color: #ffffff40;
27572+ --category-color: #8cb4ff;
27573+ --category-color-background: #8cb4ff70;
27574+ --code-color: #c1cff1;
27575+ --mark-color: #004d92;
27576+ --blend-color: #00080;
27577+ --text-primary-red: #ff97a0;
27578+ --text-primary-green: #00d061;
27579+ --text-primary-blue: #8cb4ff;
27580+ --text-primary-yellow: #c7b700;
27581+ --collections-link: #ff97a0;
27582+ --collections-header: #40000a;
27583+ --collections-mandala: #9e0027;
27584+ --collections-icon: #d30038;
27585+ --updates-link: #8cb4ff;
27586+ --updates-header: #000;
27587+ --updates-mandala: #c1cff1;
27588+ --updates-icon: #8cb4ff;
27589+ --form-limit-color: #9e9e9e;
27590+ --form-limit-color-emphasis: #b3b3b3;
27591+ --form-invalid-color: #ff97a0;
27592+ --form-invalid-focus-color: #ff707f;
27593+ --form-invalid-focus-effect-color: #ff707f33;
27594+
27595+ --a-normal-text: #4db4ff;
27596+ --a-normal-underline: #8b8b8b;
27597+ --a-visited-underline: #707070;
27598+ --a-hover-bg: #bfbfbf40;
27599+ --a-active-text: #c00;
27600+ --a-active-underline: #c00;
27601+ --tag-border-color: #000;
27602+
27603+ color-scheme: dark;
27604+ }
27605+ }
27606+
27607+
27608+
27609+ body>main.layout {
27610+ width: 100%;
27611+ height: 100%;
27612+ overflow-wrap: anywhere;
27613+
27614+ display: grid;
27615+ grid:
27616+ "header header header" auto
27617+ "leftside body rightside" 1fr
27618+ "footer footer footer" auto
27619+ / auto 1fr auto;
27620+ gap: 8px;
27621+ }
27622+
27623+ main.layout>.header { grid-area: header; }
27624+ main.layout>.leftside { grid-area: leftside; }
27625+ main.layout>div.body {
27626+ grid-area: body;
27627+ width: 90vw;
27628+ justify-self: center;
27629+ align-self: start;
27630+ }
27631+ main.layout>.rightside { grid-area: rightside; }
27632+ main.layout>footer {
27633+ font-family: var(--grotesque-system-stack);
27634+ grid-area: footer;
27635+ border-top: 2px inset;
27636+ margin-block-start: 1rem;
27637+ border-color: var(--text-link);
27638+ background-color: var(--text-primary-blue);
27639+ color: var(--text-invert);
27640+ }
27641+
27642+ main.layout>footer a[href] {
27643+ box-shadow: 2px 2px 2px black;
27644+ background: Canvas;
27645+ border: .3rem solid Canvas;
27646+ border-radius: 3px;
27647+ font-weight: bold;
27648+ font-family: var(--monospace-system-stack);
27649+ font-size: small;
27650+ }
27651+
27652+ main.layout>footer>* {
27653+ margin-block-start: 1rem;
27654+ margin-inline-start: 1rem;
27655+ margin-block-end: 1rem;
27656+ }
27657+
27658+ main.layout>div.header>h1 {
27659+ margin: 1rem;
27660+ font-family: var(--grotesque-system-stack);
27661+ font-size: xx-large;
27662+ }
27663+
27664+ main.layout>div.header>p.site-subtitle {
27665+ margin: 1rem;
27666+ margin-top: 0px;
27667+ font-family: var(--grotesque-system-stack);
27668+ font-size: large;
27669+ }
27670+
27671+ main.layout>div.header>div.page-header {
27672+ width: 90vw;
27673+ margin: 0px auto;
27674+ }
27675+
27676+ main.layout>div.header>div.page-header>nav:first-child {
27677+ margin-top: 1rem;
27678+ }
27679+
27680+ main.layout>div.body *:is(h2,h3,h4,h5,h6) {
27681+ padding-bottom: .3em;
27682+ border-bottom: 1px solid var(--horizontal-rule);
27683+ }
27684+
27685+ nav.main-nav {
27686+ padding: 0rem 1rem;
27687+ border: 1px solid var(--border-secondary);
27688+ border-left: none;
27689+ border-right: none;
27690+ border-radius: 2px;
27691+ padding: 10px 14px 10px 10px;
27692+ margin-bottom: 10px;
27693+ }
27694+
27695+ nav.main-nav>ul {
27696+ display: flex;
27697+ flex-wrap: wrap;
27698+ gap: 1rem;
27699+ }
27700+ nav.main-nav>ul>li>a {
27701+ /* fallback if clamp() isn't supported */
27702+ padding: 1rem;
27703+ padding: 1rem clamp(0.6svw,1rem,0.5vmin);
27704+ }
27705+ nav.main-nav > ul > li > a:hover {
27706+ outline: 0.1rem solid;
27707+ outline-offset: -0.5rem;
27708+ }
27709+ nav.main-nav >ul .push {
27710+ margin-left: auto;
27711+ }
27712+
27713+ main.layout>div.header h2.page-title {
27714+ margin: 1rem 0px;
27715+ font-family: var(--grotesque-system-stack);
27716+ }
27717+
27718+ nav.breadcrumbs {
27719+ padding: 10px 14px 10px 0px;
27720+ }
27721+
27722+ nav.breadcrumbs ol {
27723+ list-style-type: none;
27724+ padding-left: 0;
27725+ font-size: small;
27726+ }
27727+
27728+ /* If only the root crumb is visible, hide it to avoid unnecessary visual clutter */
27729+ li.crumb:only-child>span[aria-current="page"] {
27730+ --secs: 150ms;
27731+ transition: all var(--secs) linear;
27732+ color: transparent;
27733+ }
27734+
27735+ li.crumb:only-child>span[aria-current="page"]:hover {
27736+ transition: all var(--secs) linear;
27737+ color: revert;
27738+ }
27739+
27740+ .crumb, .crumb>a {
27741+ display: inline;
27742+ }
27743+
27744+ .crumb a::after {
27745+ display: inline-block;
27746+ color: var(--text-primary);
27747+ content: '>';
27748+ content: '>' / '';
27749+ font-size: 80%;
27750+ font-weight: bold;
27751+ padding: 0 3px;
27752+ }
27753+
27754+ .crumb span[aria-current="page"] {
27755+ color: var(--text-faded);
27756+ padding: 0.4rem;
27757+ margin-left: -0.4rem;
27758+ display: inline;
27759+ }
27760+
27761+ ul.messagelist {
27762+ list-style-type: none;
27763+ margin: 0;
27764+ padding: 0;
27765+ background: var(--background-secondary);
27766+ }
27767+
27768+ ul.messagelist:not(:empty) {
27769+ margin-block-end: 0.5rem;
27770+ }
27771+
27772+ ul.messagelist>li {
27773+ padding: 1rem 0.7rem;
27774+ --message-background: var(--icon-secondary);
27775+ background: var(--message-background);
27776+ border: 1px outset var(--message-background);
27777+ border-radius: 2px;
27778+ font-weight: 400;
27779+ margin-block-end: 1.0rem;
27780+ color: #0d0b0b;
27781+ }
27782+
27783+ ul.messagelist>li>span.label {
27784+ text-transform: capitalize;
27785+ font-weight: bolder;
27786+ }
27787+
27788+ ul.messagelist>li.error {
27789+ --message-background: var(--icon-critical);
27790+ }
27791+
27792+ ul.messagelist>li.success {
27793+ --message-background: var(--icon-success);
27794+ }
27795+
27796+ ul.messagelist>li.warning {
27797+ --message-background: var(--icon-warning);
27798+ }
27799+
27800+ ul.messagelist>li.info {
27801+ --message-background: var(--icon-information);
27802+ }
27803+
27804+ div.body>section {
27805+ display: flex;
27806+ flex-direction: column;
27807+ gap: 1rem;
27808+ }
27809+
27810+ div.body>section+section{
27811+ margin-top: 1rem;
27812+ }
27813+
27814+ div.calendar rt {
27815+ white-space: nowrap;
27816+ font-size: 50%;
27817+ -moz-min-font-size-ratio: 50%;
27818+ line-height: 1;
27819+ }
27820+ @supports not (display: ruby-text) {
27821+ /* Chrome seems to display it at regular size, so scale it down */
27822+ div.calendar rt {
27823+ scale: 50%;
27824+ font-size: 100%;
27825+ }
27826+ }
27827+
27828+ div.calendar rt {
27829+ display: ruby-text;
27830+ }
27831+
27832+ div.calendar th {
27833+ padding: 0.5rem;
27834+ opacity: 0.7;
27835+ text-align: center;
27836+ }
27837+
27838+ div.calendar tr {
27839+ text-align: right;
27840+ }
27841+
27842+ div.calendar tr,
27843+ div.calendar th {
27844+ font-variant-numeric: tabular-nums;
27845+ font-family: var(--monospace-system-stack);
27846+ }
27847+
27848+ div.calendar table {
27849+ display: inline-table;
27850+ border-collapse: collapse;
27851+ }
27852+
27853+ div.calendar td {
27854+ padding: 0.1rem 0.4rem;
27855+ font-size: 80%;
27856+ width: 2.3rem;
27857+ height: 2.3rem;
27858+ text-align: center;
27859+ }
27860+
27861+ div.calendar td.empty {
27862+ color: var(--text-faded);
27863+ }
27864+
27865+ div.calendar td:not(.empty) {
27866+ font-weight: bold;
27867+ }
27868+
27869+ div.calendar td:not(:empty) {
27870+ border: 1px solid var(--text-faded);
27871+ }
27872+
27873+ div.calendar td:empty {
27874+ background: var(--text-faded);
27875+ opacity: 0.2;
27876+ }
27877+
27878+ div.calendar {
27879+ display: flex;
27880+ flex-wrap: wrap;
27881+ flex-direction: row;
27882+ gap: 1rem;
27883+ align-items: baseline;
27884+ }
27885+
27886+ div.calendar caption {
27887+ font-weight: bold;
27888+ }
27889+
27890+ div.entries {
27891+ display: flex;
27892+ flex-direction: column;
27893+ }
27894+
27895+ div.entries>p:first-child {
27896+ margin: 1rem 0rem;
27897+ }
27898+
27899+ div.entries>div.entry {
27900+ display: flex;
27901+ flex-direction: column;
27902+ gap: 0.5rem;
27903+ border: 1px solid var(--border-secondary);
27904+ padding: 1rem 1rem;
27905+ }
27906+
27907+ div.entries>div.entry+div.entry {
27908+ border-top:none;
27909+ }
27910+
27911+ div.entries>div.entry>span.subject>a {
27912+ /* increase surface area for clicks */
27913+ padding: 1rem;
27914+ margin: -1rem;
27915+ }
27916+
27917+ div.entries>div.entry span.metadata.replies {
27918+ background: CanvasText;
27919+ border-radius: .6rem;
27920+ color: Canvas;
27921+ padding: 0.1rem 0.4rem;
27922+ font-size: small;
27923+ font-variant-numeric: tabular-nums;
27924+ }
27925+
27926+ div.entries>div.entry>span.metadata {
27927+ font-size: small;
27928+ color: var(--text-faded);
27929+ word-break: break-all;
27930+ }
27931+
27932+ div.entries>div.entry span.value {
27933+ max-width: 44ch;
27934+ display: inline-block;
27935+ white-space: break-spaces;
27936+ word-wrap: anywhere;
27937+ word-break: break-all;
27938+ vertical-align: top;
27939+ }
27940+
27941+ div.entries>div.entry span.value.empty {
27942+ color: var(--text-faded);
27943+ }
27944+
27945+ div.posts>div.entry>span.metadata>span.from {
27946+ margin-inline-end: 1rem;
27947+ }
27948+
27949+ table.headers {
27950+ padding: .5rem 0 .5rem 1rem;
27951+ }
27952+
27953+ table.headers tr>th {
27954+ text-align: left;
27955+ color: var(--text-faded);
27956+ }
27957+
27958+ table.headers th[scope="row"] {
27959+ padding-right: .5rem;
27960+ vertical-align: top;
27961+ font-family: var(--grotesque-system-stack);
27962+ }
27963+
27964+ table.headers tr>td {
27965+ overflow-wrap: break-word;
27966+ hyphens: auto;
27967+ word-wrap: anywhere;
27968+ word-break: break-all;
27969+ width: auto;
27970+ }
27971+
27972+ div.post-body>pre {
27973+ border-top: 1px solid;
27974+ overflow-wrap: break-word;
27975+ white-space: pre-line;
27976+ hyphens: auto;
27977+ /* background-color: var(--background-secondary); */
27978+ line-height: 1.1;
27979+ padding: 1rem;
27980+ }
27981+
27982+ div.post {
27983+ border-top: 1px solid var(--horizontal-rule);
27984+ border-right: 1px solid var(--horizontal-rule);
27985+ border-left: 1px solid var(--horizontal-rule);
27986+ border-bottom: 1px solid var(--horizontal-rule);
27987+ }
27988+ div.post:not(:first-child) {
27989+ border-top: none;
27990+ }
27991+
27992+ td.message-id,
27993+ span.message-id{
27994+ color: var(--text-faded);
27995+ }
27996+ .message-id>a {
27997+ overflow-wrap: break-word;
27998+ hyphens: auto;
27999+ }
28000+ td.message-id:before,
28001+ span.message-id:before{
28002+ content: '<';
28003+ display: inline-block;
28004+ opacity: 0.6;
28005+ }
28006+ td.message-id:after,
28007+ span.message-id:after{
28008+ content: '>';
28009+ display: inline-block;
28010+ opacity: 0.6;
28011+ }
28012+ span.message-id + span.message-id:before{
28013+ content: ', <';
28014+ display: inline-block;
28015+ opacity: 0.6;
28016+ }
28017+ td.faded,
28018+ span.faded {
28019+ color: var(--text-faded);
28020+ }
28021+ td.faded:is(:focus, :hover, :focus-visible, :focus-within),
28022+ span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
28023+ color: revert;
28024+ }
28025+ tr>td>details.reply-details ~ tr {
28026+ display: none;
28027+ }
28028+ tr>td>details.reply-details[open] ~ tr {
28029+ display: revert;
28030+ }
28031+
28032+ ul.lists {
28033+ padding: 1rem 2rem;
28034+ }
28035+
28036+ ul.lists li {
28037+ list-style: disc;
28038+ }
28039+
28040+ ul.lists li + li {
28041+ margin-top: 0.2rem;
28042+ }
28043+
28044+ dl.lists dt {
28045+ font-weight: bold;
28046+ font-size: 1.2rem;
28047+ padding-bottom: .3em;
28048+ background: #88929d36;
28049+ padding: .2rem .2rem;
28050+ border-radius: .2rem;
28051+ }
28052+
28053+ dl.lists dd > * + * {
28054+ margin-top: 1rem;
28055+ }
28056+
28057+ dl.lists dd .list-topics,
28058+ dl.lists dd .list-posts-dates {
28059+ display: block;
28060+ width: 100%;
28061+ }
28062+
28063+ dl.lists dl,
28064+ dl.lists dd {
28065+ font-size: small;
28066+ }
28067+
28068+ dl.lists dd {
28069+ /* fallback in case margin-block-* is not supported */
28070+ margin-bottom: 1rem;
28071+ margin-block-start: 0.3rem;
28072+ margin-block-end: 1rem;
28073+ line-height: 1.5;
28074+ }
28075+
28076+ dl.lists .no-description {
28077+ color: var(--text-faded);
28078+ }
28079+
28080+ hr {
28081+ margin: 1rem 0rem;
28082+ border-bottom: 1px solid #88929d;
28083+ }
28084+
28085+ .command-line-example {
28086+ user-select: all;
28087+ display: inline-block;
28088+ ruby-align: center;
28089+ ruby-position: under;
28090+
28091+ background: var(--code-background);
28092+ outline: 1px inset var(--code-background);
28093+ border-radius: 1px;
28094+ color: var(--code-foreground);
28095+ font-weight: 500;
28096+ width: auto;
28097+ max-width: 90vw;
28098+ padding: 1.2rem 0.8rem 1rem 0.8rem;
28099+ overflow-wrap: break-word;
28100+ overflow: auto;
28101+ white-space: pre;
28102+ }
28103+
28104+ textarea.key-or-sig-input {
28105+ font-family: var(--monospace-system-stack);
28106+ font-size: 0.5rem;
28107+ font-weight: 400;
28108+ width: auto;
28109+ height: 26rem;
28110+ max-width: min(71ch, 100%);
28111+ overflow-wrap: break-word;
28112+ overflow: auto;
28113+ white-space: pre;
28114+ line-height: 1rem;
28115+ vertical-align: top;
28116+ }
28117+
28118+ textarea.key-or-sig-input.wrap {
28119+ word-wrap: anywhere;
28120+ word-break: break-all;
28121+ white-space: break-spaces;
28122+ }
28123+
28124+ .login-ssh textarea#id_password::placeholder {
28125+ line-height: 1rem;
28126+ }
28127+
28128+ mark.ssh-challenge-token {
28129+ font-family: var(--monospace-system-stack);
28130+ overflow-wrap: anywhere;
28131+ }
28132+
28133+ .body-grid {
28134+ display: grid;
28135+ /* fallback */
28136+ grid-template-columns: 1fr;
28137+ grid-template-columns: fit-content(100%);
28138+ grid-auto-rows: min-content;
28139+ row-gap: min(6vw, 1rem);
28140+ width: 100%;
28141+ height: 100%;
28142+ }
28143+
28144+ form.login-form {
28145+ display: flex;
28146+ flex-direction: column;
28147+ gap: 8px;
28148+ max-width: 98vw;
28149+ width: auto;
28150+ }
28151+
28152+ form.login-form > :not([type="hidden"]) + label, fieldset > :not([type="hidden"], legend) + label {
28153+ margin-top: 1rem;
28154+ }
28155+
28156+ form.settings-form {
28157+ display: grid;
28158+ grid-template-columns: auto;
28159+ gap: 1rem;
28160+ max-width: 90vw;
28161+ width: auto;
28162+ overflow: auto;
28163+ }
28164+
28165+ form.settings-form>input[type="submit"] {
28166+ place-self: start;
28167+ }
28168+
28169+ form.settings-form>fieldset {
28170+ padding: 1rem 1.5rem 2rem 1.5rem;
28171+ }
28172+
28173+ form.settings-form>fieldset>legend {
28174+ padding: .5rem 1rem;
28175+ border: 1px ridge var(--text-faded);
28176+ font-weight: bold;
28177+ font-size: small;
28178+ margin-left: 0.8rem;
28179+ }
28180+
28181+ form.settings-form>fieldset>div {
28182+ display: flex;
28183+ flex-direction: row;
28184+ flex-wrap: nowrap;
28185+ align-items: center;
28186+ }
28187+
28188+ form.settings-form>fieldset>div>label:last-child {
28189+ padding: 1rem 0 1rem 1rem;
28190+ flex-grow: 2;
28191+ max-width: max-content;
28192+ }
28193+
28194+ form.settings-form>fieldset>div>label:first-child {
28195+ padding: 1rem 1rem 1rem 0rem;
28196+ flex-grow: 2;
28197+ max-width: max-content;
28198+ }
28199+
28200+ form.settings-form>fieldset>div>:not(label):not(input) {
28201+ flex-grow: 8;
28202+ width: auto;
28203+ }
28204+
28205+ form.settings-form>fieldset>div>input {
28206+ margin: 0.8rem;
28207+ }
28208+
28209+ form.settings-form>fieldset>table tr>th {
28210+ text-align: right;
28211+ padding-right: 1rem;
28212+ }
28213+
28214+ button, input {
28215+ overflow: visible;
28216+ }
28217+
28218+ button, input, optgroup, select, textarea {
28219+ font-family: inherit;
28220+ font-size: 100%;
28221+ line-height: 1.15;
28222+ margin: 0;
28223+ }
28224+
28225+ form label {
28226+ font-weight: 500;
28227+ }
28228+
28229+ textarea {
28230+ max-width: var(--main-width);
28231+ width: 100%;
28232+ resize: both;
28233+ }
28234+ textarea {
28235+ overflow: auto;
28236+ }
28237+
28238+ button, [type="button"], [type="reset"], [type="submit"] {
28239+ -webkit-appearance: button;
28240+ }
28241+
28242+ input, textarea {
28243+ display: inline-block;
28244+ appearance: auto;
28245+ -moz-default-appearance: textfield;
28246+ padding: 1px;
28247+ border: 2px inset ButtonBorder;
28248+ border-radius: 5px;
28249+ padding: .5rem;
28250+ background-color: Field;
28251+ color: FieldText;
28252+ font: -moz-field;
28253+ text-rendering: optimizeLegibility;
28254+ cursor: text;
28255+ }
28256+
28257+ input[type="text"], textarea {
28258+ outline: 3px inset #6969694a;
28259+ outline-offset: -5px;
28260+ }
28261+
28262+ button, ::file-selector-button, input:is([type="color"], [type="reset"], [type="button"], [type="submit"]) {
28263+ appearance: auto;
28264+ -moz-default-appearance: button;
28265+ padding-block: 1px;
28266+ padding-inline: 8px;
28267+ border: 2px outset ButtonBorder;
28268+ border-radius: 3px;
28269+ background-color: ButtonFace;
28270+ cursor: default;
28271+ box-sizing: border-box;
28272+ user-select: none;
28273+ padding: .5rem;
28274+ min-width: 10rem;
28275+ align-self: start;
28276+ }
28277+
28278+ button:disabled, input:is([type="color"], [type="reset"], [type="button"], [type="submit"]):disabled {
28279+ color: var(--text-faded);
28280+ background: Field;
28281+ cursor: not-allowed;
28282+ }
28283+
28284+ ol.list {
28285+ list-style: decimal outside;
28286+ padding-inline-start: 4rem;
28287+ }
28288+
28289+ .screen-reader-only {
28290+ position:absolute;
28291+ left:-500vw;
28292+ top:auto;
28293+ width:1px;
28294+ height:1px;
28295+ overflow:hidden;
28296+ }
28297+
28298+ ul.tags {
28299+ list-style: none;
28300+ margin: 0;
28301+ padding: 0;
28302+ height: max-content;
28303+ vertical-align: baseline;
28304+ display: inline-flex;
28305+ gap: 0.8ex;
28306+ flex-flow: row wrap;
28307+ }
28308+
28309+ .tag {
28310+ --aa-brightness: calc(((var(--red) * 299) + (var(--green) * 587) + (var(--blue) * 114)) / 1000);
28311+ --aa-color: calc((var(--aa-brightness) - 128) * -1000);
28312+
28313+ --padding-top-bottom: 0.2rem;
28314+ --padding-left-right: .5rem;
28315+ --padding-top-bottom: 0.5rem;
28316+ --height: calc(1.5cap + var(--padding-top-bottom));
28317+ /* fallback */
28318+ max-height: 1rem;
28319+ max-height: var(--height);
28320+ min-height: 1.45rem;
28321+ /* fallback */
28322+ line-height: 1.3;
28323+ line-height: calc(var(--height) / 2);
28324+ min-width: max-content;
28325+ /* fallback */
28326+ min-height: 1rem;
28327+ min-height: var(--height);
28328+
28329+ display: inline-block;
28330+ border: 1px solid var(--tag-border-color);
28331+ border-radius:.2rem;
28332+ color: #555;
28333+ font-size: 1.05rem;
28334+ padding: calc(var(--padding-top-bottom) / 2) var(--padding-left-right) var(--padding-top-bottom) var(--padding-left-right);
28335+ text-decoration: none;
28336+ background: rgb(var(--red), var(--green), var(--blue));
28337+ color: rgb(var(--aa-color), var(--aa-color), var(--aa-color));
28338+ }
28339+
28340+ span.tag-name a {
28341+ text-decoration: none;
28342+ color: inherit;
28343+ }
28344+
28345+ blockquote {
28346+ margin-inline: 0 var(--gap);
28347+ padding-inline: var(--gap) 0;
28348+ margin-block: var(--gap);
28349+ font-size: 1.1em;
28350+ line-height: var(--rhythm);
28351+ font-style: italic;
28352+ border-inline-start: 1px solid var(--graphical-fg);
28353+ color: var(--muted-fg);
28354+ }
28355+
28356+ time, .tabular-nums {
28357+ font-family: var(--grotesque-system-stack);
28358+ font-variant-numeric: tabular-nums slashed-zero;
28359+ }
28360+
28361+ a[href^="#"].anchor::before {
28362+ color: var(--text-inactive);
28363+ content: "#";
28364+ display: inline-block;
28365+ font-size: .7em;
28366+ line-height: 1;
28367+ margin-left: -.8em;
28368+ text-decoration: none;
28369+ visibility: hidden;
28370+ width: .8em;
28371+ }
28372+ </style>
28373 diff --git a/mailpot-web/src/templates/footer.html b/mailpot-web/src/templates/footer.html
28374new file mode 100644
28375index 0000000..15b74a9
28376--- /dev/null
28377+++ b/mailpot-web/src/templates/footer.html
28378 @@ -0,0 +1,6 @@
28379+ <footer>
28380+ <p>Generated by <a href="https://github.com/meli/mailpot" target="_blank">mailpot</a>.</p>
28381+ </footer>
28382+ </main>
28383+ </body>
28384+ </html>
28385 diff --git a/mailpot-web/src/templates/header.html b/mailpot-web/src/templates/header.html
28386new file mode 100644
28387index 0000000..d4ad75e
28388--- /dev/null
28389+++ b/mailpot-web/src/templates/header.html
28390 @@ -0,0 +1,35 @@
28391+ <!DOCTYPE html>
28392+ <html lang="en">
28393+ <head>
28394+ <meta charset="utf-8">
28395+ <meta name="viewport" content="width=device-width, initial-scale=1">
28396+ <title>{{ title if title else page_title if page_title else site_title }}</title>{% if canonical_url %}
28397+ <link href="{{ urlize(canonical_url) }}" rel="canonical" />{% endif %}
28398+ {% include "css.html" %}
28399+ </head>
28400+ <body>
28401+ <main class="layout">
28402+ <div class="header">
28403+ <h1><bdi>{{ site_title }}</bdi></h1>
28404+ {% if site_subtitle %}
28405+ <p class="site-subtitle"><bdi>{{ site_subtitle|safe }}</bdi></p>
28406+ {% endif %}
28407+ {% include "menu.html" %}
28408+ <div class="page-header">
28409+ {% if crumbs|length > 1 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs">
28410+ <ol id="breadcrumb-menu" role="menu" aria-label="Breadcrumb menu">{% for crumb in crumbs %}<li class="crumb" aria-describedby="bread_{{ loop.index }}">{% if loop.last %}<span role="menuitem" id="bread_{{ loop.index }}" aria-current="page" title="current page">{{ crumb.label }}</span>{% else %}<a role="menuitem" id="bread_{{ loop.index }}" href="{{ urlize(crumb.url) }}" tabindex="0">{{ crumb.label }}</a>{% endif %}</li>{% endfor %}</ol>
28411+ </nav>{% endif %}
28412+ {% if page_title %}
28413+ <h2 class="page-title"><bdi>{{ page_title }}</bdi></h2>
28414+ {% endif %}
28415+ {% if messages %}
28416+ <ul class="messagelist">
28417+ {% for message in messages %}
28418+ <li class="{{ message.level|lower }}">
28419+ <span class="label">{{ message.level }}: </span>{{ message.message }}
28420+ </li>
28421+ {% endfor %}
28422+ </ul>
28423+ {% endif %}
28424+ </div>
28425+ </div>
28426 diff --git a/mailpot-web/src/templates/help.html b/mailpot-web/src/templates/help.html
28427new file mode 100644
28428index 0000000..3c846ae
28429--- /dev/null
28430+++ b/mailpot-web/src/templates/help.html
28431 @@ -0,0 +1,20 @@
28432+ {% include "header.html" %}
28433+ <div class="body body-grid">
28434+ {{ heading(3, "Subscribing to a list") }}
28435+
28436+ <p>A mailing list can have different subscription policies, or none at all (which would disable subscriptions). If subscriptions are open or require manual approval by the list owners, you can send an e-mail request to its <code>+request</code> sub-address with the subject <code>subscribe</code>.</p>
28437+
28438+ {{ heading(3, "Unsubscribing from a list") }}
28439+
28440+ <p>Similarly to subscribing, send an e-mail request to the list's <code>+request</code> sub-address with the subject <code>unsubscribe</code>.</p>
28441+
28442+ {{ heading(3, "Do I need an account?") }}
28443+
28444+ <p>An account's utility is only to manage your subscriptions and preferences from the web interface. Thus you don't need one if you want to perform all list operations from your e-mail client instead.</p>
28445+
28446+ {{ heading(3, "Creating an account") }}
28447+
28448+ <p>After successfully subscribing to a list, simply send an e-mail request to its <code>+request</code> sub-address with the subject <code>password</code> and an SSH public key in the e-mail body as plain text.</p>
28449+ <p>This will either create you an account with this key, or change your existing key if you already have one.</p>
28450+ </div>
28451+ {% include "footer.html" %}
28452 diff --git a/mailpot-web/src/templates/index.html b/mailpot-web/src/templates/index.html
28453new file mode 100644
28454index 0000000..c2a6c97
28455--- /dev/null
28456+++ b/mailpot-web/src/templates/index.html
28457 @@ -0,0 +1,11 @@
28458+ {% include "header.html" %}
28459+ <div class="entry">
28460+ <div class="body">
28461+ <ul>
28462+ {% for l in lists %}
28463+ <li><a href="{{ list_path(l.list.id) }}"><bdi>{{ l.list.name }}</bdi></a></li>
28464+ {% endfor %}
28465+ </ul>
28466+ </div>
28467+ </div>
28468+ {% include "footer.html" %}
28469 diff --git a/mailpot-web/src/templates/lists.html b/mailpot-web/src/templates/lists.html
28470new file mode 100644
28471index 0000000..5f1a6d8
28472--- /dev/null
28473+++ b/mailpot-web/src/templates/lists.html
28474 @@ -0,0 +1,13 @@
28475+ {% include "header.html" %}
28476+ <div class="body">
28477+ <!-- {{ lists|length }} lists -->
28478+ <div class="entry">
28479+ <dl class="lists" aria-label="list of mailing lists">
28480+ {% for l in lists %}
28481+ <dt aria-label="mailing list name"><a href="{{ list_path(l.list.id) }}"><bdi>{{ l.list.name }}</bdi></a></dt>
28482+ <dd><span aria-label="mailing list description"{% if not l.list.description %} class="no-description"{% endif %}>{{ l.list.description if l.list.description else "<p>no description</p>"|safe }}</span><span class="list-posts-dates tabular-nums">{{ l.posts|length }} post{{ l.posts|length|pluralize("","s") }}{% if l.newest %} | <time datetime="{{ l.newest }}">{{ l.newest }}</time>{% endif %}</span>{% if l.list.topics|length > 0 %}<span class="list-topics"><span>Topics:</span>&nbsp;{{ l.list.topics() }}</span>{% endif %}</dd>
28483+ {% endfor %}
28484+ </dl>
28485+ </div>
28486+ </div>
28487+ {% include "footer.html" %}
28488 diff --git a/mailpot-web/src/templates/lists/edit.html b/mailpot-web/src/templates/lists/edit.html
28489new file mode 100644
28490index 0000000..02c3ef3
28491--- /dev/null
28492+++ b/mailpot-web/src/templates/lists/edit.html
28493 @@ -0,0 +1,156 @@
28494+ {% include "header.html" %}
28495+ <div class="body body-grid">
28496+ {{ heading(3, "Edit <a href=\"" ~list_path(list.id) ~ "\">"~ list.id ~"</a>","edit") }}
28497+ <address>
28498+ {{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
28499+ </address>
28500+ {% if list.description %}
28501+ {% if list.is_description_html_safe %}
28502+ {{ list.description|safe}}
28503+ {% else %}
28504+ <p>{{ list.description }}</p>
28505+ {% endif %}
28506+ {% endif %}
28507+ {% if list.archive_url %}
28508+ <p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
28509+ {% endif %}
28510+ <p><a href="{{ list_subscribers_path(list.id) }}">{{ subs_count }} subscription{{ subs_count|pluralize }}.</a></p>
28511+ <p><a href="{{ list_candidates_path(list.id) }}">{{ sub_requests_count }} subscription request{{ sub_requests_count|pluralize }}.</a></p>
28512+ <p>{{ post_count }} post{{ post_count|pluralize }}.</p>
28513+ <form method="post" class="settings-form">
28514+ <fieldset>
28515+ <input type="hidden" name="type" value="metadata">
28516+ <legend>List Metadata</legend>
28517+
28518+ <table>
28519+ <tr>
28520+ <th>
28521+ <label for="id_name">List name.</label>
28522+ </th>
28523+ <td>
28524+ <input type="text" name="name" id="id_name" value="{{ list.name }}">
28525+ </td>
28526+ </tr>
28527+ <tr>
28528+ <th>
28529+ <label for="id_list_id">List ID.</label>
28530+ </th>
28531+ <td>
28532+ <input type="text" name="id" id="id_list_id" value="{{ list.id }}">
28533+ </td>
28534+ </tr>
28535+ <tr>
28536+ <th>
28537+ <label for="id_description">List description.</label>
28538+ </th>
28539+ <td>
28540+ <textarea name="description" id="id_description">{{ list.description if list.description else "" }}</textarea>
28541+ </td>
28542+ </tr>
28543+ <tr>
28544+ <th>
28545+ <label for="id_list_address">List address.</label>
28546+ </th>
28547+ <td>
28548+ <input type="email" name="address" id="id_list_address" value="{{ list.address }}">
28549+ </td>
28550+ </tr>
28551+ <tr>
28552+ <th>
28553+ <label for="id_owner_local_part">List owner local part.</label>
28554+ </th>
28555+ <td>
28556+ <input type="text" name="owner_local_part" id="id_owner_local_part" value="{{ list.owner_local_part if list.owner_local_part else "" }}">
28557+ </td>
28558+ </tr>
28559+ <tr>
28560+ <th>
28561+ <label for="id_request_local_part">List request local part.</label>
28562+ </th>
28563+ <td>
28564+ <input type="text" name="request_local_part" id="id_request_local_part" value="{{ list.request_local_part if list.request_local_part else "" }}">
28565+ </td>
28566+ </tr>
28567+ <tr>
28568+ <th>
28569+ <label for="id_archive_url">List archive URL.</label>
28570+ </th>
28571+ <td>
28572+ <input type="text" name="archive_url" id="id_archive_url" value="{{ list.archive_url if list.archive_url else "" }}">
28573+ </td>
28574+ </tr>
28575+ </table>
28576+ </fieldset>
28577+
28578+ <input type="submit" name="metadata" value="Update list">
28579+ </form>
28580+ <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
28581+ <fieldset>
28582+ <input type="hidden" name="type" value="post-policy">
28583+ <legend>Post Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>
28584+ {% if not post_policy %}
28585+ <ul class="messagelist">
28586+ <li class="info">
28587+ <span class="label">Info: </span>No post policy set. Press Create to add one.
28588+ </li>
28589+ </ul>
28590+ {% endif %}
28591+ <div>
28592+ <input type="radio" required="" name="post-policy" id="post-announce-only" value="announce-only"{% if post_policy.announce_only %} checked{% endif %}>
28593+ <label for="post-announce-only">Announce only</label>
28594+ </div>
28595+ <div>
28596+ <input type="radio" required="" name="post-policy" id="post-subscription-only" value="subscription-only"{% if post_policy.subscription_only %} checked{% endif %}>
28597+ <label for="post-subscription-only">Subscription only</label>
28598+ </div>
28599+ <div>
28600+ <input type="radio" required="" name="post-policy" id="post-approval-needed" value="approval-needed"{% if post_policy.approval_needed %} checked{% endif %}>
28601+ <label for="post-approval-needed">Approval needed</label>
28602+ </div>
28603+ <div>
28604+ <input type="radio" required="" name="post-policy" id="post-open" value="open"{% if post_policy.open %} checked{% endif %}>
28605+ <label for="post-open">Open</label>
28606+ </div>
28607+ <div>
28608+ <input type="radio" required="" name="post-policy" id="post-custom" value="custom"{% if post_policy.custom %} checked{% endif %}>
28609+ <label for="post-custom">Custom</label>
28610+ </div>
28611+ </fieldset>
28612+ <input type="submit" value="{{ "Update" if post_policy else "Create" }} Post Policy">
28613+ </form>
28614+ <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
28615+ <fieldset>
28616+ <input type="hidden" name="type" value="subscription-policy">
28617+ <legend>Subscription Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>
28618+ {% if not subscription_policy %}
28619+ <ul class="messagelist">
28620+ <li class="info">
28621+ <span class="label">Info: </span>No subscription policy set. Press Create to add one.
28622+ </li>
28623+ </ul>
28624+ {% endif %}
28625+ <div>
28626+ <input type="checkbox" value="true" name="send-confirmation" id="sub-send-confirmation"{% if subscription_policy.send_confirmation %} checked{% endif %}>
28627+ <label for="sub-send-confirmation">Send confirmation to new subscribers.</label>
28628+ </div>
28629+ <div>
28630+ <input type="radio" required="" name="subscription-policy" id="sub-open" value="open"{% if subscription_policy.open %} checked{% endif %}>
28631+ <label for="sub-open">Open</label>
28632+ </div>
28633+ <div>
28634+ <input type="radio" required="" name="subscription-policy" id="sub-manual" value="manual"{% if subscription_policy.manual %} checked{% endif %}>
28635+ <label for="sub-manual">Manual</label>
28636+ </div>
28637+ <div>
28638+ <input type="radio" required="" name="subscription-policy" id="sub-request" value="request"{% if subscription_policy.request %} checked{% endif %}>
28639+ <label for="sub-request">Request</label>
28640+ </div>
28641+ <div>
28642+ <input type="radio" required="" name="subscription-policy" id="sub-custom" value="custom"{% if subscription_policy.custom %} checked{% endif %}>
28643+ <label for="sub-custom">Custom</label>
28644+ </div>
28645+ </fieldset>
28646+ <input type="submit" value="{{ "Update" if subscription_policy else "Create" }} Subscription Policy">
28647+ </form>
28648+ </div>
28649+ {% include "footer.html" %}
28650 diff --git a/mailpot-web/src/templates/lists/entry.html b/mailpot-web/src/templates/lists/entry.html
28651new file mode 100644
28652index 0000000..6920257
28653--- /dev/null
28654+++ b/mailpot-web/src/templates/lists/entry.html
28655 @@ -0,0 +1,39 @@
28656+ <div class="post" id="{{ strip_carets(post.message_id)|safe }}">
28657+ <table class="headers" title="E-mail headers">
28658+ <caption class="screen-reader-only">E-mail headers</caption>
28659+ <tr>
28660+ <th scope="row"></th>
28661+ <td><a href="#{{ strip_carets(post.message_id) }}"></a></td>
28662+ </tr>
28663+ <tr>
28664+ <th scope="row">From:</th>
28665+ <td><bdi>{{ post.address }}</bdi></td>
28666+ </tr>
28667+ <tr>
28668+ <th scope="row">Date:</th>
28669+ <td class="faded">{{ post.datetime }}</td>
28670+ </tr>
28671+ <tr>
28672+ <th scope="row">Message-ID:</th>
28673+ <td class="faded message-id"><a href="{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
28674+ </tr>
28675+ {% if in_reply_to %}
28676+ <tr>
28677+ <th scope="row">In-Reply-To:</th>
28678+ <td class="faded message-id"><a href="{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
28679+ </tr>
28680+ {% endif %}
28681+ {% if references %}
28682+ <tr>
28683+ <th scope="row">References:</th>
28684+ <td>{% for r in references %}<span class="faded message-id"><a href="{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
28685+ </tr>
28686+ {% endif %}
28687+ <tr>
28688+ <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>
28689+ </tr>
28690+ </table>
28691+ <div class="post-body">
28692+ <pre {% if odd %}style="--background-secondary: var(--background-critical);" {% endif %}title="E-mail text content">{{ body|trim }}</pre>
28693+ </div>
28694+ </div>
28695 diff --git a/mailpot-web/src/templates/lists/list.html b/mailpot-web/src/templates/lists/list.html
28696new file mode 100644
28697index 0000000..18fe31a
28698--- /dev/null
28699+++ b/mailpot-web/src/templates/lists/list.html
28700 @@ -0,0 +1,114 @@
28701+ {% include "header.html" %}
28702+ <div class="body">
28703+ {% if list.topics|length > 0 %}<span><em>Topics</em>:</span>&nbsp;{{ list.topics() }}
28704+ <br aria-hidden="true">
28705+ <br aria-hidden="true">
28706+ {% endif %}
28707+ {% if list.description %}
28708+ <p title="mailing list description">{{ list.description }}</p>
28709+ {% else %}
28710+ <p title="mailing list description">No list description.</p>
28711+ {% endif %}
28712+ <br aria-hidden="true">
28713+ {% if current_user and subscription_policy and subscription_policy.open %}
28714+ {% if user_context %}
28715+ <form method="post" action="{{ settings_path() }}" class="settings-form">
28716+ <input type="hidden" name="type", value="unsubscribe">
28717+ <input type="hidden" name="list_pk", value="{{ list.pk }}">
28718+ <input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
28719+ </form>
28720+ <br />
28721+ {% else %}
28722+ <form method="post" action="{{ settings_path() }}" class="settings-form">
28723+ <input type="hidden" name="type", value="subscribe">
28724+ <input type="hidden" name="list_pk", value="{{ list.pk }}">
28725+ <input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
28726+ </form>
28727+ <br />
28728+ {% endif %}
28729+ {% endif %}
28730+ {% if preamble %}
28731+ <section id="preamble" class="preamble" aria-label="mailing list instructions">
28732+ {% if preamble.custom %}
28733+ {{ preamble.custom|safe }}
28734+ {% else %}
28735+ {% if subscription_policy %}
28736+ {% if subscription_policy.open or subscription_policy.request %}
28737+ {{ heading(3, "Subscribe") }}
28738+ {% set subscription_mailto=list.subscription_mailto() %}
28739+ {% if subscription_mailto %}
28740+ {% if subscription_mailto.subject %}
28741+ <p>
28742+ <a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
28743+ </p>
28744+ {% else %}
28745+ <p>
28746+ <a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
28747+ </p>
28748+ {% endif %}
28749+ {% else %}
28750+ <p>List is not open for subscriptions.</p>
28751+ {% endif %}
28752+
28753+ {% set unsubscription_mailto=list.unsubscription_mailto() %}
28754+ {% if unsubscription_mailto %}
28755+ {{ heading(3, "Unsubscribe") }}
28756+ {% if unsubscription_mailto.subject %}
28757+ <p>
28758+ <a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
28759+ </p>
28760+ {% else %}
28761+ <p>
28762+ <a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
28763+ </p>
28764+ {% endif %}
28765+ {% endif %}
28766+ {% endif %}
28767+ {% endif %}
28768+
28769+ {% if post_policy %}
28770+ {{ heading(3, "Post") }}
28771+ {% if post_policy.announce_only %}
28772+ <p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
28773+ {% elif post_policy.subscription_only %}
28774+ <p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
28775+ <p>If you are subscribed, you can send new posts to:
28776+ <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
28777+ </p>
28778+ {% elif post_policy.approval_needed or post_policy.no_subscriptions %}
28779+ <p>List is open to all posts <em>after approval</em> by the list owners.</p>
28780+ <p>You can send new posts to:
28781+ <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
28782+ </p>
28783+ {% else %}
28784+ <p>List is not open for submissions.</p>
28785+ {% endif %}
28786+ {% endif %}
28787+ {% endif %}
28788+ </section>
28789+ {% endif %}
28790+ <section class="list" aria-hidden="true">
28791+ {{ heading(3, "Calendar") }}
28792+ <div class="calendar">
28793+ {%- from "calendar.html" import cal %}
28794+ {% for date in months %}
28795+ {{ cal(date, hists) }}
28796+ {% endfor %}
28797+ </div>
28798+ </section>
28799+ <section aria-label="mailing list posts">
28800+ {{ heading(3, "Posts") }}
28801+ <div class="posts entries" role="list" aria-label="list of mailing list posts">
28802+ <p>{{ posts | length }} post{{ posts|length|pluralize }}</p>
28803+ {% for post in posts %}
28804+ <div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}">
28805+ <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
28806+ <span class="metadata"><span aria-hidden="true">👤&nbsp;</span><span class="from" title="post author"><bdi>{{ post.address }}</bdi></span><span aria-hidden="true"> 📆&nbsp;</span><span class="date" title="post date">{{ post.datetime }}</span></span>
28807+ {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">&#x1F493;&nbsp;</span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %}
28808+ <span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>
28809+ </div>
28810+ {% endfor %}
28811+ </div>
28812+ </section>
28813+ </div>
28814+ {% include "footer.html" %}
28815 diff --git a/mailpot-web/src/templates/lists/post.html b/mailpot-web/src/templates/lists/post.html
28816new file mode 100644
28817index 0000000..a0d07e5
28818--- /dev/null
28819+++ b/mailpot-web/src/templates/lists/post.html
28820 @@ -0,0 +1,13 @@
28821+ {% include "header.html" %}
28822+ <div class="body">
28823+ {% set is_root = true %}
28824+ {% with post = { 'address': from, 'to': to, 'datetime': date, 'message_id': message_id } %}
28825+ {% include 'lists/entry.html' %}
28826+ {% endwith %}
28827+ {% set is_root = false %}
28828+ {% for (depth, post, body, date) in thread %}
28829+ {% set odd = loop.index % 2 == 1 %}
28830+ {% include 'lists/entry.html' %}
28831+ {% endfor %}
28832+ </div>
28833+ {% include "footer.html" %}
28834 diff --git a/mailpot-web/src/templates/lists/sub-requests.html b/mailpot-web/src/templates/lists/sub-requests.html
28835new file mode 100644
28836index 0000000..72d6137
28837--- /dev/null
28838+++ b/mailpot-web/src/templates/lists/sub-requests.html
28839 @@ -0,0 +1,57 @@
28840+ {% include "header.html" %}
28841+ <div class="body body-grid">
28842+ <style>
28843+ table {
28844+ border-collapse: collapse;
28845+ border: 2px solid rgb(200,200,200);
28846+ letter-spacing: 1px;
28847+ }
28848+
28849+ td, th {
28850+ border: 1px solid rgb(190,190,190);
28851+ padding: 0.1rem 1rem;
28852+ }
28853+
28854+ th {
28855+ background-color: var(--background-tertiary);
28856+ }
28857+
28858+ td {
28859+ text-align: center;
28860+ }
28861+
28862+ caption {
28863+ padding: 10px;
28864+ }
28865+ </style>
28866+ <p>{{ subs|length }} entr{{ subs|length|pluralize("y","ies") }}.</a></p>
28867+ {% if subs %}
28868+ <div style="overflow: scroll;">
28869+ <table>
28870+ <tr>
28871+ {% for key,val in subs|first|items %}
28872+ <th>{{ key }}</th>
28873+ {% endfor %}
28874+ <th></th>
28875+ </tr>
28876+ {% for s in subs %}
28877+ <tr>
28878+ {% for key,val in s|items %}
28879+ <td>{{ val }}</td>
28880+ {% endfor %}
28881+ <td>
28882+ {% if not s.accepted %}
28883+ <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
28884+ <input type="hidden" name="type" value="accept-subscription-request">
28885+ <input type="hidden" name="pk" value="{{ s.pk }}">
28886+ <input type="submit" value="Accept">
28887+ </form>
28888+ {% endif %}
28889+ </td>
28890+ </tr>
28891+ {% endfor %}
28892+ </table>
28893+ </div>
28894+ {% endif %}
28895+ </div>
28896+ {% include "footer.html" %}
28897 diff --git a/mailpot-web/src/templates/lists/subs.html b/mailpot-web/src/templates/lists/subs.html
28898new file mode 100644
28899index 0000000..3b7cc7c
28900--- /dev/null
28901+++ b/mailpot-web/src/templates/lists/subs.html
28902 @@ -0,0 +1,47 @@
28903+ {% include "header.html" %}
28904+ <div class="body body-grid">
28905+ <style>
28906+ table {
28907+ border-collapse: collapse;
28908+ border: 2px solid rgb(200,200,200);
28909+ letter-spacing: 1px;
28910+ }
28911+
28912+ td, th {
28913+ border: 1px solid rgb(190,190,190);
28914+ padding: 0.1rem 1rem;
28915+ }
28916+
28917+ th {
28918+ background-color: var(--background-tertiary);
28919+ }
28920+
28921+ td {
28922+ text-align: center;
28923+ }
28924+
28925+ caption {
28926+ padding: 10px;
28927+ }
28928+ </style>
28929+ <p>{{ subs|length }} entr{{ subs|length|pluralize("y","ies") }}.</a></p>
28930+ {% if subs %}
28931+ <div style="overflow: scroll;">
28932+ <table>
28933+ <tr>
28934+ {% for key,val in subs|first|items %}
28935+ <th>{{ key }}</th>
28936+ {% endfor %}
28937+ </tr>
28938+ {% for s in subs %}
28939+ <tr>
28940+ {% for key,val in s|items %}
28941+ <td>{{ val }}</td>
28942+ {% endfor %}
28943+ </tr>
28944+ {% endfor %}
28945+ </table>
28946+ </div>
28947+ {% endif %}
28948+ </div>
28949+ {% include "footer.html" %}
28950 diff --git a/mailpot-web/src/templates/menu.html b/mailpot-web/src/templates/menu.html
28951new file mode 100644
28952index 0000000..ea9b627
28953--- /dev/null
28954+++ b/mailpot-web/src/templates/menu.html
28955 @@ -0,0 +1,11 @@
28956+ <nav class="main-nav" aria-label="main menu" role="menu">
28957+ <ul>
28958+ <li><a role="menuitem" href="{{ urlize("") }}/">Index</a></li>
28959+ <li><a role="menuitem" href="{{ help_path() }}">Help&nbsp;&amp; Documentation</a></li>
28960+ {% if current_user %}
28961+ <li class="push">Settings: <a role="menuitem" href="{{ settings_path() }}" title="User settings"><bdi>{{ current_user.address }}</bdi></a></li>
28962+ {% else %}
28963+ <li class="push"><a role="menuitem" href="{{ login_path() }}" title="login with one time password using your SSH key">Login with SSH OTP</a></li>
28964+ {% endif %}
28965+ </ul>
28966+ </nav>
28967 diff --git a/mailpot-web/src/templates/settings.html b/mailpot-web/src/templates/settings.html
28968new file mode 100644
28969index 0000000..1a6bdc0
28970--- /dev/null
28971+++ b/mailpot-web/src/templates/settings.html
28972 @@ -0,0 +1,83 @@
28973+ {% include "header.html" %}
28974+ <div class="body body-grid">
28975+ {{ heading(3,"Your account","account") }}
28976+ <div class="entries">
28977+ <div class="entry">
28978+ <span>Display name: <span class="value{% if not user.name %} empty{% endif %}"><bdi>{{ user.name if user.name else "None" }}</bdi></span></span>
28979+ </div>
28980+ <div class="entry">
28981+ <span>Address: <span class="value">{{ user.address }}</span></span>
28982+ </div>
28983+ <div class="entry">
28984+ <span>PGP public key: <span class="value{% if not user.public_key %} empty{% endif %}">{{ user.public_key if user.public_key else "None." }}</span></span>
28985+ </div>
28986+ <div class="entry">
28987+ <span>SSH public key: <span class="value{% if not user.password %} empty{% endif %}">{{ user.password if user.password else "None." }}</span></span>
28988+ </div>
28989+ </div>
28990+
28991+ {{ heading(4,"List Subscriptions") }}
28992+ <div class="entries">
28993+ <p>{{ subscriptions | length }} subscription(s)</p>
28994+ {% for (s, list) in subscriptions %}
28995+ <div class="entry">
28996+ <span class="subject"><a href="{{ list_settings_path(list.id) }}">{{ list.name }}</a></span>
28997+ <!-- span class="metadata">📆&nbsp;<span>{{ s.created }}</span></span -->
28998+ </div>
28999+ {% endfor %}
29000+ </div>
29001+
29002+ {{ heading(4,"Account Settings") }}
29003+ <form method="post" action="{{ settings_path() }}" class="settings-form">
29004+ <input type="hidden" name="type" value="change-name">
29005+ <fieldset>
29006+ <legend>Change display name</legend>
29007+
29008+ <div>
29009+ <label for="id_name">New name:</label>
29010+ <input type="text" name="new" id="id_name" value="{{ user.name if user.name else "" }}">
29011+ </div>
29012+ </fieldset>
29013+ <input type="submit" name="change" value="Change">
29014+ </form>
29015+
29016+ <form method="post" action="{{ settings_path() }}" class="settings-form">
29017+ <input type="hidden" name="type" value="change-password">
29018+ <fieldset>
29019+ <legend>Change SSH public key</legend>
29020+
29021+ <div>
29022+ <label for="id_ssh_public_key">New SSH public key:</label>
29023+ <textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_ssh_public_key">{{ user.password if user.password else "" }}</textarea>
29024+ </div>
29025+ </fieldset>
29026+ <input type="submit" name="change" value="Change">
29027+ </form>
29028+
29029+ <form method="post" action="{{ settings_path() }}" class="settings-form">
29030+ <input type="hidden" name="type" value="change-public-key">
29031+ <fieldset>
29032+ <legend>Change PGP public key</legend>
29033+
29034+ <div>
29035+ <label for="id_public_key">New PGP public key:</label>
29036+ <textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_public_key">{{ user.public_key if user.public_key else "" }}</textarea>
29037+ </div>
29038+ </fieldset>
29039+ <input type="submit" name="change-public-key" value="Change">
29040+ </form>
29041+
29042+ <form method="post" action="{{ settings_path() }}" class="settings-form">
29043+ <input type="hidden" name="type" value="remove-public-key">
29044+ <fieldset>
29045+ <legend>Remove PGP public key</legend>
29046+
29047+ <div>
29048+ <input type="checkbox" required="" name="remove-public-keyim-sure" id="remove-public-key-im-sure">
29049+ <label for="remove-public-key-im-sure">I am certain I want to remove my PGP public key.</label>
29050+ </div>
29051+ </fieldset>
29052+ <input type="submit" name="remove-public-key" value="Remove">
29053+ </form>
29054+ </div>
29055+ {% include "footer.html" %}
29056 diff --git a/mailpot-web/src/templates/settings_subscription.html b/mailpot-web/src/templates/settings_subscription.html
29057new file mode 100644
29058index 0000000..e36d187
29059--- /dev/null
29060+++ b/mailpot-web/src/templates/settings_subscription.html
29061 @@ -0,0 +1,61 @@
29062+ {% include "header.html" %}
29063+ <div class="body body-grid">
29064+ {{ heading(3, "Your subscription to <a href=\"" ~ list_path(list.id) ~ "\">" ~ list.id ~ "</a>.","subscription") }}
29065+ <address>
29066+ <bdi>{{ list.name }}</bdi> <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
29067+ </address>
29068+ {% if list.is_description_html_safe %}
29069+ {{ list.description|safe}}
29070+ {% else %}
29071+ <p><bdi>{{ list.description }}</bdi></p>
29072+ {% endif %}
29073+ {% if list.archive_url %}
29074+ <p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
29075+ {% endif %}
29076+ <form method="post" class="settings-form">
29077+ <fieldset>
29078+ <legend>subscription settings</legend>
29079+
29080+ <div>
29081+ <input type="checkbox" value="true" name="digest" id="id_digest"{% if subscription.digest %} checked{% endif %}>
29082+ <label for="id_digest">Receive posts as a digest.</label>
29083+ </div>
29084+
29085+ <div>
29086+ <input type="checkbox" value="true" name="hide_address" id="id_hide_address"{% if subscription.hide_address %} checked{% endif %}>
29087+ <label for="id_hide_address">Hide your e-mail address in your posts.</label>
29088+ </div>
29089+
29090+ <div>
29091+ <input type="checkbox" value="true" name="receive_duplicates" id="id_receive_duplicates"{% if subscription.receive_duplicates %} checked{% endif %}>
29092+ <label for="id_receive_duplicates">Receive mailing list post duplicates, <abbr title="that is">i.e.</abbr> posts addressed both to you and the mailing list to which you are subscribed.</label>
29093+ </div>
29094+
29095+ <div>
29096+ <input type="checkbox" value="true" name="receive_own_posts" id="id_receive_own_posts"{% if subscription.receive_own_posts %} checked{% endif %}>
29097+ <label for="id_receive_own_posts">Receive your own mailing list posts from the mailing list.</label>
29098+ </div>
29099+
29100+ <div>
29101+ <input type="checkbox" value="true" name="receive_confirmation" id="id_receive_confirmation"{% if subscription.receive_confirmation %} checked{% endif %}>
29102+ <label for="id_receive_confirmation">Receive a plain confirmation for your own mailing list posts.</label>
29103+ </div>
29104+ </fieldset>
29105+
29106+ <input type="submit" value="Update settings">
29107+ <input type="hidden" name="next" value="">
29108+ </form>
29109+ <form method="post" action="{{ settings_path() }}" class="settings-form">
29110+ <fieldset>
29111+ <input type="hidden" name="type" value="unsubscribe">
29112+ <input type="hidden" name="list_pk" value="{{ list.pk }}">
29113+ <legend>Unsubscribe</legend>
29114+ <div>
29115+ <input type="checkbox" required="" name="im-sure" id="unsubscribe-im-sure">
29116+ <label for="unsubscribe-im-sure">I am certain I want to unsubscribe.</label>
29117+ </div>
29118+ </fieldset>
29119+ <input type="submit" name="subscribe" value="Unsubscribe">
29120+ </form>
29121+ </div>
29122+ {% include "footer.html" %}
29123 diff --git a/mailpot-web/src/templates/topics.html b/mailpot-web/src/templates/topics.html
29124new file mode 100644
29125index 0000000..ec5b8d3
29126--- /dev/null
29127+++ b/mailpot-web/src/templates/topics.html
29128 @@ -0,0 +1,13 @@
29129+ {% include "header.html" %}
29130+ <div class="body">
29131+ <p style="margin-block-end: 1rem;">Results for <bdi><em>{{ term }}</em></bdi></p>
29132+ <div class="entry">
29133+ <dl class="lists" aria-label="list of mailing lists">
29134+ {% for list in results %}
29135+ <dt aria-label="mailing list name"><a href="{{ list_path(list.id) }}">{{ list.id }}</a></dt>
29136+ <dd><span aria-label="mailing list description"{% if not list.description %} class="no-description"{% endif %}>{{ list.description if list.description else "<p>no description</p>"|safe }}</span>{% if list.topics|length > 0 %}<span class="list-topics"><span>Topics:</span>&nbsp;{{ list.topics_html() }}</span>{% endif %}</dd>
29137+ {% endfor %}
29138+ </dl>
29139+ </div>
29140+ </div>
29141+ {% include "footer.html" %}
29142 diff --git a/mailpot-web/src/topics.rs b/mailpot-web/src/topics.rs
29143new file mode 100644
29144index 0000000..13c2b9a
29145--- /dev/null
29146+++ b/mailpot-web/src/topics.rs
29147 @@ -0,0 +1,153 @@
29148+ /*
29149+ * This file is part of mailpot
29150+ *
29151+ * Copyright 2020 - Manos Pitsidianakis
29152+ *
29153+ * This program is free software: you can redistribute it and/or modify
29154+ * it under the terms of the GNU Affero General Public License as
29155+ * published by the Free Software Foundation, either version 3 of the
29156+ * License, or (at your option) any later version.
29157+ *
29158+ * This program is distributed in the hope that it will be useful,
29159+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
29160+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29161+ * GNU Affero General Public License for more details.
29162+ *
29163+ * You should have received a copy of the GNU Affero General Public License
29164+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
29165+ */
29166+
29167+ use super::*;
29168+
29169+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
29170+ pub struct SearchTerm {
29171+ query: Option<String>,
29172+ }
29173+
29174+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
29175+ pub struct SearchResult {
29176+ pk: i64,
29177+ id: String,
29178+ description: Option<String>,
29179+ topics: Vec<String>,
29180+ }
29181+
29182+ impl std::fmt::Display for SearchResult {
29183+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
29184+ write!(fmt, "{:?}", self)
29185+ }
29186+ }
29187+
29188+ impl Object for SearchResult {
29189+ fn kind(&self) -> minijinja::value::ObjectKind {
29190+ minijinja::value::ObjectKind::Struct(self)
29191+ }
29192+
29193+ fn call_method(
29194+ &self,
29195+ _state: &minijinja::State,
29196+ name: &str,
29197+ _args: &[Value],
29198+ ) -> std::result::Result<Value, Error> {
29199+ match name {
29200+ "topics_html" => crate::minijinja_utils::topics_common(&self.topics),
29201+ _ => Err(Error::new(
29202+ minijinja::ErrorKind::UnknownMethod,
29203+ format!("object has no method named {name}"),
29204+ )),
29205+ }
29206+ }
29207+ }
29208+
29209+ impl minijinja::value::StructObject for SearchResult {
29210+ fn get_field(&self, name: &str) -> Option<Value> {
29211+ match name {
29212+ "pk" => Some(Value::from_serializable(&self.pk)),
29213+ "id" => Some(Value::from_serializable(&self.id)),
29214+ "description" => Some(
29215+ self.description
29216+ .clone()
29217+ .map(Value::from_safe_string)
29218+ .unwrap_or_else(|| Value::from_serializable(&self.description)),
29219+ ),
29220+ "topics" => Some(Value::from_serializable(&self.topics)),
29221+ _ => None,
29222+ }
29223+ }
29224+
29225+ fn static_fields(&self) -> Option<&'static [&'static str]> {
29226+ Some(&["pk", "id", "description", "topics"][..])
29227+ }
29228+ }
29229+ pub async fn list_topics(
29230+ _: TopicsPath,
29231+ mut session: WritableSession,
29232+ Query(SearchTerm { query: term }): Query<SearchTerm>,
29233+ auth: AuthContext,
29234+ State(state): State<Arc<AppState>>,
29235+ ) -> Result<Html<String>, ResponseError> {
29236+ let db = Connection::open_db(state.conf.clone())?.trusted();
29237+
29238+ let results: Vec<Value> = {
29239+ if let Some(term) = term.as_ref() {
29240+ let mut stmt = db.connection.prepare(
29241+ "SELECT DISTINCT list.pk, list.id, list.description, list.topics FROM list, \
29242+ json_each(list.topics) WHERE json_each.value IS ?;",
29243+ )?;
29244+ let iter = stmt.query_map([&term], |row| {
29245+ let pk = row.get(0)?;
29246+ let id = row.get(1)?;
29247+ let description = row.get(2)?;
29248+ let topics = mailpot::models::MailingList::topics_from_json_value(row.get(3)?)?;
29249+ Ok(Value::from_object(SearchResult {
29250+ pk,
29251+ id,
29252+ description,
29253+ topics,
29254+ }))
29255+ })?;
29256+ let mut ret = vec![];
29257+ for el in iter {
29258+ let el = el?;
29259+ ret.push(el);
29260+ }
29261+ ret
29262+ } else {
29263+ db.lists()?
29264+ .into_iter()
29265+ .map(DbVal::into_inner)
29266+ .map(|l| SearchResult {
29267+ pk: l.pk,
29268+ id: l.id,
29269+ description: l.description,
29270+ topics: l.topics,
29271+ })
29272+ .map(Value::from_object)
29273+ .collect()
29274+ }
29275+ };
29276+
29277+ let crumbs = vec![
29278+ Crumb {
29279+ label: "Home".into(),
29280+ url: "/".into(),
29281+ },
29282+ Crumb {
29283+ label: "Search for topics".into(),
29284+ url: TopicsPath.to_crumb(),
29285+ },
29286+ ];
29287+ let context = minijinja::context! {
29288+ canonical_url => TopicsPath.to_crumb(),
29289+ term,
29290+ results,
29291+ page_title => "Topic Search Results",
29292+ description => "",
29293+ current_user => auth.current_user,
29294+ messages => session.drain_messages(),
29295+ crumbs,
29296+ };
29297+ Ok(Html(
29298+ TEMPLATES.get_template("topics.html")?.render(context)?,
29299+ ))
29300+ }
29301 diff --git a/mailpot-web/src/typed_paths.rs b/mailpot-web/src/typed_paths.rs
29302new file mode 100644
29303index 0000000..6e0b3de
29304--- /dev/null
29305+++ b/mailpot-web/src/typed_paths.rs
29306 @@ -0,0 +1,610 @@
29307+ /*
29308+ * This file is part of mailpot
29309+ *
29310+ * Copyright 2020 - Manos Pitsidianakis
29311+ *
29312+ * This program is free software: you can redistribute it and/or modify
29313+ * it under the terms of the GNU Affero General Public License as
29314+ * published by the Free Software Foundation, either version 3 of the
29315+ * License, or (at your option) any later version.
29316+ *
29317+ * This program is distributed in the hope that it will be useful,
29318+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
29319+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29320+ * GNU Affero General Public License for more details.
29321+ *
29322+ * You should have received a copy of the GNU Affero General Public License
29323+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
29324+ */
29325+
29326+ pub use mailpot::PATH_SEGMENT;
29327+ use percent_encoding::utf8_percent_encode;
29328+
29329+ use super::*;
29330+
29331+ pub trait IntoCrumb: TypedPath {
29332+ fn to_crumb(&self) -> Cow<'static, str> {
29333+ Cow::from(self.to_uri().to_string())
29334+ }
29335+ }
29336+
29337+ impl<TP: TypedPath> IntoCrumb for TP {}
29338+
29339+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
29340+ #[serde(untagged)]
29341+ pub enum ListPathIdentifier {
29342+ Pk(#[serde(deserialize_with = "parse_int")] i64),
29343+ Id(String),
29344+ }
29345+
29346+ fn parse_int<'de, T, D>(de: D) -> Result<T, D::Error>
29347+ where
29348+ D: serde::Deserializer<'de>,
29349+ T: std::str::FromStr,
29350+ <T as std::str::FromStr>::Err: std::fmt::Display,
29351+ {
29352+ use serde::Deserialize;
29353+ String::deserialize(de)?
29354+ .parse()
29355+ .map_err(serde::de::Error::custom)
29356+ }
29357+
29358+ impl From<i64> for ListPathIdentifier {
29359+ fn from(val: i64) -> Self {
29360+ Self::Pk(val)
29361+ }
29362+ }
29363+
29364+ impl From<String> for ListPathIdentifier {
29365+ fn from(val: String) -> Self {
29366+ Self::Id(val)
29367+ }
29368+ }
29369+
29370+ impl std::fmt::Display for ListPathIdentifier {
29371+ #[allow(clippy::unnecessary_to_owned)]
29372+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29373+ let id: Cow<'_, str> = match self {
29374+ Self::Pk(id) => id.to_string().into(),
29375+ Self::Id(id) => id.into(),
29376+ };
29377+ write!(f, "{}", utf8_percent_encode(&id, PATH_SEGMENT,))
29378+ }
29379+ }
29380+
29381+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29382+ #[typed_path("/list/:id/")]
29383+ pub struct ListPath(pub ListPathIdentifier);
29384+
29385+ impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
29386+ fn from(val: &DbVal<mailpot::models::MailingList>) -> Self {
29387+ Self(ListPathIdentifier::Id(val.id.clone()))
29388+ }
29389+ }
29390+
29391+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29392+ #[typed_path("/list/:id/posts/:msgid/")]
29393+ pub struct ListPostPath(pub ListPathIdentifier, pub String);
29394+
29395+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29396+ #[typed_path("/list/:id/posts/:msgid/raw/")]
29397+ pub struct ListPostRawPath(pub ListPathIdentifier, pub String);
29398+
29399+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29400+ #[typed_path("/list/:id/posts/:msgid/eml/")]
29401+ pub struct ListPostEmlPath(pub ListPathIdentifier, pub String);
29402+
29403+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29404+ #[typed_path("/list/:id/edit/")]
29405+ pub struct ListEditPath(pub ListPathIdentifier);
29406+
29407+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29408+ #[typed_path("/list/:id/edit/subscribers/")]
29409+ pub struct ListEditSubscribersPath(pub ListPathIdentifier);
29410+
29411+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29412+ #[typed_path("/list/:id/edit/candidates/")]
29413+ pub struct ListEditCandidatesPath(pub ListPathIdentifier);
29414+
29415+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29416+ #[typed_path("/settings/list/:id/")]
29417+ pub struct ListSettingsPath(pub ListPathIdentifier);
29418+
29419+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29420+ #[typed_path("/login/")]
29421+ pub struct LoginPath;
29422+
29423+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29424+ #[typed_path("/logout/")]
29425+ pub struct LogoutPath;
29426+
29427+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29428+ #[typed_path("/settings/")]
29429+ pub struct SettingsPath;
29430+
29431+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29432+ #[typed_path("/help/")]
29433+ pub struct HelpPath;
29434+
29435+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
29436+ #[typed_path("/topics/")]
29437+ pub struct TopicsPath;
29438+
29439+ macro_rules! unit_impl {
29440+ ($ident:ident, $ty:expr) => {
29441+ pub fn $ident(state: &minijinja::State) -> std::result::Result<Value, Error> {
29442+ urlize(state, Value::from($ty.to_crumb().to_string()))
29443+ }
29444+ };
29445+ }
29446+
29447+ unit_impl!(login_path, LoginPath);
29448+ unit_impl!(logout_path, LogoutPath);
29449+ unit_impl!(settings_path, SettingsPath);
29450+ unit_impl!(help_path, HelpPath);
29451+
29452+ macro_rules! list_id_impl {
29453+ ($ident:ident, $ty:tt) => {
29454+ pub fn $ident(state: &minijinja::State, id: Value) -> std::result::Result<Value, Error> {
29455+ urlize(
29456+ state,
29457+ if let Some(id) = id.as_str() {
29458+ Value::from(
29459+ $ty(ListPathIdentifier::Id(id.to_string()))
29460+ .to_crumb()
29461+ .to_string(),
29462+ )
29463+ } else {
29464+ let pk = id.try_into()?;
29465+ Value::from($ty(ListPathIdentifier::Pk(pk)).to_crumb().to_string())
29466+ },
29467+ )
29468+ }
29469+ };
29470+ }
29471+
29472+ list_id_impl!(list_path, ListPath);
29473+ list_id_impl!(list_settings_path, ListSettingsPath);
29474+ list_id_impl!(list_edit_path, ListEditPath);
29475+ list_id_impl!(list_subscribers_path, ListEditSubscribersPath);
29476+ list_id_impl!(list_candidates_path, ListEditCandidatesPath);
29477+
29478+ macro_rules! list_post_impl {
29479+ ($ident:ident, $ty:tt) => {
29480+ pub fn $ident(
29481+ state: &minijinja::State,
29482+ id: Value,
29483+ msg_id: Value,
29484+ ) -> std::result::Result<Value, Error> {
29485+ urlize(state, {
29486+ let Some(msg_id) = msg_id
29487+ .as_str()
29488+ .map(|s| s.to_string().strip_carets_inplace())
29489+ else {
29490+ return Err(Error::new(
29491+ minijinja::ErrorKind::UnknownMethod,
29492+ "Second argument of list_post_path must be a string.",
29493+ ));
29494+ };
29495+
29496+ if let Some(id) = id.as_str() {
29497+ Value::from(
29498+ $ty(ListPathIdentifier::Id(id.to_string()), msg_id)
29499+ .to_crumb()
29500+ .to_string(),
29501+ )
29502+ } else {
29503+ let pk = id.try_into()?;
29504+ Value::from(
29505+ $ty(ListPathIdentifier::Pk(pk), msg_id)
29506+ .to_crumb()
29507+ .to_string(),
29508+ )
29509+ }
29510+ })
29511+ }
29512+ };
29513+ }
29514+
29515+ list_post_impl!(list_post_path, ListPostPath);
29516+ list_post_impl!(post_raw_path, ListPostRawPath);
29517+ list_post_impl!(post_eml_path, ListPostEmlPath);
29518+
29519+ pub mod tsr {
29520+ use std::{borrow::Cow, convert::Infallible};
29521+
29522+ use axum::{
29523+ http::Request,
29524+ response::{IntoResponse, Redirect, Response},
29525+ routing::{any, MethodRouter},
29526+ Router,
29527+ };
29528+ use axum_extra::routing::{RouterExt as ExtraRouterExt, SecondElementIs, TypedPath};
29529+ use http::{uri::PathAndQuery, StatusCode, Uri};
29530+ use tower_service::Service;
29531+
29532+ /// Extension trait that adds additional methods to [`Router`].
29533+ pub trait RouterExt<S, B>: ExtraRouterExt<S, B> {
29534+ /// Add a typed `GET` route to the router.
29535+ ///
29536+ /// The path will be inferred from the first argument to the handler
29537+ /// function which must implement [`TypedPath`].
29538+ ///
29539+ /// See [`TypedPath`] for more details and examples.
29540+ fn typed_get<H, T, P>(self, handler: H) -> Self
29541+ where
29542+ H: axum::handler::Handler<T, S, B>,
29543+ T: SecondElementIs<P> + 'static,
29544+ P: TypedPath;
29545+
29546+ /// Add a typed `DELETE` route to the router.
29547+ ///
29548+ /// The path will be inferred from the first argument to the handler
29549+ /// function which must implement [`TypedPath`].
29550+ ///
29551+ /// See [`TypedPath`] for more details and examples.
29552+ fn typed_delete<H, T, P>(self, handler: H) -> Self
29553+ where
29554+ H: axum::handler::Handler<T, S, B>,
29555+ T: SecondElementIs<P> + 'static,
29556+ P: TypedPath;
29557+
29558+ /// Add a typed `HEAD` route to the router.
29559+ ///
29560+ /// The path will be inferred from the first argument to the handler
29561+ /// function which must implement [`TypedPath`].
29562+ ///
29563+ /// See [`TypedPath`] for more details and examples.
29564+ fn typed_head<H, T, P>(self, handler: H) -> Self
29565+ where
29566+ H: axum::handler::Handler<T, S, B>,
29567+ T: SecondElementIs<P> + 'static,
29568+ P: TypedPath;
29569+
29570+ /// Add a typed `OPTIONS` route to the router.
29571+ ///
29572+ /// The path will be inferred from the first argument to the handler
29573+ /// function which must implement [`TypedPath`].
29574+ ///
29575+ /// See [`TypedPath`] for more details and examples.
29576+ fn typed_options<H, T, P>(self, handler: H) -> Self
29577+ where
29578+ H: axum::handler::Handler<T, S, B>,
29579+ T: SecondElementIs<P> + 'static,
29580+ P: TypedPath;
29581+
29582+ /// Add a typed `PATCH` route to the router.
29583+ ///
29584+ /// The path will be inferred from the first argument to the handler
29585+ /// function which must implement [`TypedPath`].
29586+ ///
29587+ /// See [`TypedPath`] for more details and examples.
29588+ fn typed_patch<H, T, P>(self, handler: H) -> Self
29589+ where
29590+ H: axum::handler::Handler<T, S, B>,
29591+ T: SecondElementIs<P> + 'static,
29592+ P: TypedPath;
29593+
29594+ /// Add a typed `POST` route to the router.
29595+ ///
29596+ /// The path will be inferred from the first argument to the handler
29597+ /// function which must implement [`TypedPath`].
29598+ ///
29599+ /// See [`TypedPath`] for more details and examples.
29600+ fn typed_post<H, T, P>(self, handler: H) -> Self
29601+ where
29602+ H: axum::handler::Handler<T, S, B>,
29603+ T: SecondElementIs<P> + 'static,
29604+ P: TypedPath;
29605+
29606+ /// Add a typed `PUT` route to the router.
29607+ ///
29608+ /// The path will be inferred from the first argument to the handler
29609+ /// function which must implement [`TypedPath`].
29610+ ///
29611+ /// See [`TypedPath`] for more details and examples.
29612+ fn typed_put<H, T, P>(self, handler: H) -> Self
29613+ where
29614+ H: axum::handler::Handler<T, S, B>,
29615+ T: SecondElementIs<P> + 'static,
29616+ P: TypedPath;
29617+
29618+ /// Add a typed `TRACE` route to the router.
29619+ ///
29620+ /// The path will be inferred from the first argument to the handler
29621+ /// function which must implement [`TypedPath`].
29622+ ///
29623+ /// See [`TypedPath`] for more details and examples.
29624+ fn typed_trace<H, T, P>(self, handler: H) -> Self
29625+ where
29626+ H: axum::handler::Handler<T, S, B>,
29627+ T: SecondElementIs<P> + 'static,
29628+ P: TypedPath;
29629+
29630+ /// Add another route to the router with an additional "trailing slash
29631+ /// redirect" route.
29632+ ///
29633+ /// If you add a route _without_ a trailing slash, such as `/foo`, this
29634+ /// method will also add a route for `/foo/` that redirects to
29635+ /// `/foo`.
29636+ ///
29637+ /// If you add a route _with_ a trailing slash, such as `/bar/`, this
29638+ /// method will also add a route for `/bar` that redirects to
29639+ /// `/bar/`.
29640+ ///
29641+ /// This is similar to what axum 0.5.x did by default, except this
29642+ /// explicitly adds another route, so trying to add a `/foo/`
29643+ /// route after calling `.route_with_tsr("/foo", /* ... */)`
29644+ /// will result in a panic due to route overlap.
29645+ ///
29646+ /// # Example
29647+ ///
29648+ /// ```
29649+ /// use axum::{routing::get, Router};
29650+ /// use axum_extra::routing::RouterExt;
29651+ ///
29652+ /// let app = Router::new()
29653+ /// // `/foo/` will redirect to `/foo`
29654+ /// .route_with_tsr("/foo", get(|| async {}))
29655+ /// // `/bar` will redirect to `/bar/`
29656+ /// .route_with_tsr("/bar/", get(|| async {}));
29657+ /// # let _: Router = app;
29658+ /// ```
29659+ fn route_with_tsr(self, path: &str, method_router: MethodRouter<S, B>) -> Self
29660+ where
29661+ Self: Sized;
29662+
29663+ /// Add another route to the router with an additional "trailing slash
29664+ /// redirect" route.
29665+ ///
29666+ /// This works like [`RouterExt::route_with_tsr`] but accepts any
29667+ /// [`Service`].
29668+ fn route_service_with_tsr<T>(self, path: &str, service: T) -> Self
29669+ where
29670+ T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
29671+ T::Response: IntoResponse,
29672+ T::Future: Send + 'static,
29673+ Self: Sized;
29674+ }
29675+
29676+ impl<S, B> RouterExt<S, B> for Router<S, B>
29677+ where
29678+ B: axum::body::HttpBody + Send + 'static,
29679+ S: Clone + Send + Sync + 'static,
29680+ {
29681+ fn typed_get<H, T, P>(mut self, handler: H) -> Self
29682+ where
29683+ H: axum::handler::Handler<T, S, B>,
29684+ T: SecondElementIs<P> + 'static,
29685+ P: TypedPath,
29686+ {
29687+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29688+ self = self.route(
29689+ tsr_path.as_ref(),
29690+ axum::routing::get(move |url| tsr_handler_into_async(url, tsr_handler)),
29691+ );
29692+ self = self.route(P::PATH, axum::routing::get(handler));
29693+ self
29694+ }
29695+
29696+ fn typed_delete<H, T, P>(mut self, handler: H) -> Self
29697+ where
29698+ H: axum::handler::Handler<T, S, B>,
29699+ T: SecondElementIs<P> + 'static,
29700+ P: TypedPath,
29701+ {
29702+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29703+ self = self.route(
29704+ tsr_path.as_ref(),
29705+ axum::routing::delete(move |url| tsr_handler_into_async(url, tsr_handler)),
29706+ );
29707+ self = self.route(P::PATH, axum::routing::delete(handler));
29708+ self
29709+ }
29710+
29711+ fn typed_head<H, T, P>(mut self, handler: H) -> Self
29712+ where
29713+ H: axum::handler::Handler<T, S, B>,
29714+ T: SecondElementIs<P> + 'static,
29715+ P: TypedPath,
29716+ {
29717+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29718+ self = self.route(
29719+ tsr_path.as_ref(),
29720+ axum::routing::head(move |url| tsr_handler_into_async(url, tsr_handler)),
29721+ );
29722+ self = self.route(P::PATH, axum::routing::head(handler));
29723+ self
29724+ }
29725+
29726+ fn typed_options<H, T, P>(mut self, handler: H) -> Self
29727+ where
29728+ H: axum::handler::Handler<T, S, B>,
29729+ T: SecondElementIs<P> + 'static,
29730+ P: TypedPath,
29731+ {
29732+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29733+ self = self.route(
29734+ tsr_path.as_ref(),
29735+ axum::routing::options(move |url| tsr_handler_into_async(url, tsr_handler)),
29736+ );
29737+ self = self.route(P::PATH, axum::routing::options(handler));
29738+ self
29739+ }
29740+
29741+ fn typed_patch<H, T, P>(mut self, handler: H) -> Self
29742+ where
29743+ H: axum::handler::Handler<T, S, B>,
29744+ T: SecondElementIs<P> + 'static,
29745+ P: TypedPath,
29746+ {
29747+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29748+ self = self.route(
29749+ tsr_path.as_ref(),
29750+ axum::routing::patch(move |url| tsr_handler_into_async(url, tsr_handler)),
29751+ );
29752+ self = self.route(P::PATH, axum::routing::patch(handler));
29753+ self
29754+ }
29755+
29756+ fn typed_post<H, T, P>(mut self, handler: H) -> Self
29757+ where
29758+ H: axum::handler::Handler<T, S, B>,
29759+ T: SecondElementIs<P> + 'static,
29760+ P: TypedPath,
29761+ {
29762+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29763+ self = self.route(
29764+ tsr_path.as_ref(),
29765+ axum::routing::post(move |url| tsr_handler_into_async(url, tsr_handler)),
29766+ );
29767+ self = self.route(P::PATH, axum::routing::post(handler));
29768+ self
29769+ }
29770+
29771+ fn typed_put<H, T, P>(mut self, handler: H) -> Self
29772+ where
29773+ H: axum::handler::Handler<T, S, B>,
29774+ T: SecondElementIs<P> + 'static,
29775+ P: TypedPath,
29776+ {
29777+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29778+ self = self.route(
29779+ tsr_path.as_ref(),
29780+ axum::routing::put(move |url| tsr_handler_into_async(url, tsr_handler)),
29781+ );
29782+ self = self.route(P::PATH, axum::routing::put(handler));
29783+ self
29784+ }
29785+
29786+ fn typed_trace<H, T, P>(mut self, handler: H) -> Self
29787+ where
29788+ H: axum::handler::Handler<T, S, B>,
29789+ T: SecondElementIs<P> + 'static,
29790+ P: TypedPath,
29791+ {
29792+ let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
29793+ self = self.route(
29794+ tsr_path.as_ref(),
29795+ axum::routing::trace(move |url| tsr_handler_into_async(url, tsr_handler)),
29796+ );
29797+ self = self.route(P::PATH, axum::routing::trace(handler));
29798+ self
29799+ }
29800+
29801+ #[track_caller]
29802+ fn route_with_tsr(mut self, path: &str, method_router: MethodRouter<S, B>) -> Self
29803+ where
29804+ Self: Sized,
29805+ {
29806+ validate_tsr_path(path);
29807+ self = self.route(path, method_router);
29808+ add_tsr_redirect_route(self, path)
29809+ }
29810+
29811+ #[track_caller]
29812+ fn route_service_with_tsr<T>(mut self, path: &str, service: T) -> Self
29813+ where
29814+ T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
29815+ T::Response: IntoResponse,
29816+ T::Future: Send + 'static,
29817+ Self: Sized,
29818+ {
29819+ validate_tsr_path(path);
29820+ self = self.route_service(path, service);
29821+ add_tsr_redirect_route(self, path)
29822+ }
29823+ }
29824+
29825+ #[track_caller]
29826+ fn validate_tsr_path(path: &str) {
29827+ if path == "/" {
29828+ panic!("Cannot add a trailing slash redirect route for `/`")
29829+ }
29830+ }
29831+
29832+ #[inline]
29833+ fn add_tsr_redirect_route<S, B>(router: Router<S, B>, path: &str) -> Router<S, B>
29834+ where
29835+ B: axum::body::HttpBody + Send + 'static,
29836+ S: Clone + Send + Sync + 'static,
29837+ {
29838+ async fn redirect_handler(uri: Uri) -> Response {
29839+ let new_uri = map_path(uri, |path| {
29840+ path.strip_suffix('/')
29841+ .map(Cow::Borrowed)
29842+ .unwrap_or_else(|| Cow::Owned(format!("{path}/")))
29843+ });
29844+
29845+ new_uri.map_or_else(
29846+ || StatusCode::BAD_REQUEST.into_response(),
29847+ |new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
29848+ )
29849+ }
29850+
29851+ if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
29852+ router.route(path_without_trailing_slash, any(redirect_handler))
29853+ } else {
29854+ router.route(&format!("{path}/"), any(redirect_handler))
29855+ }
29856+ }
29857+
29858+ #[inline]
29859+ fn tsr_redirect_route(path: &'_ str) -> (Cow<'_, str>, fn(Uri) -> Response) {
29860+ fn redirect_handler(uri: Uri) -> Response {
29861+ let new_uri = map_path(uri, |path| {
29862+ path.strip_suffix('/')
29863+ .map(Cow::Borrowed)
29864+ .unwrap_or_else(|| Cow::Owned(format!("{path}/")))
29865+ });
29866+
29867+ new_uri.map_or_else(
29868+ || StatusCode::BAD_REQUEST.into_response(),
29869+ |new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
29870+ )
29871+ }
29872+
29873+ path.strip_suffix('/').map_or_else(
29874+ || {
29875+ (
29876+ Cow::Owned(format!("{path}/")),
29877+ redirect_handler as fn(Uri) -> Response,
29878+ )
29879+ },
29880+ |path_without_trailing_slash| {
29881+ (
29882+ Cow::Borrowed(path_without_trailing_slash),
29883+ redirect_handler as fn(Uri) -> Response,
29884+ )
29885+ },
29886+ )
29887+ }
29888+
29889+ #[inline]
29890+ async fn tsr_handler_into_async(u: Uri, h: fn(Uri) -> Response) -> Response {
29891+ h(u)
29892+ }
29893+
29894+ /// Map the path of a `Uri`.
29895+ ///
29896+ /// Returns `None` if the `Uri` cannot be put back together with the new
29897+ /// path.
29898+ fn map_path<F>(original_uri: Uri, f: F) -> Option<Uri>
29899+ where
29900+ F: FnOnce(&str) -> Cow<'_, str>,
29901+ {
29902+ let mut parts = original_uri.into_parts();
29903+ let path_and_query = parts.path_and_query.as_ref()?;
29904+
29905+ let new_path = f(path_and_query.path());
29906+
29907+ let new_path_and_query = if let Some(query) = &path_and_query.query() {
29908+ format!("{new_path}?{query}").parse::<PathAndQuery>().ok()?
29909+ } else {
29910+ new_path.parse::<PathAndQuery>().ok()?
29911+ };
29912+ parts.path_and_query = Some(new_path_and_query);
29913+
29914+ Uri::from_parts(parts).ok()
29915+ }
29916+ }
29917 diff --git a/mailpot-web/src/utils.rs b/mailpot-web/src/utils.rs
29918new file mode 100644
29919index 0000000..60217ee
29920--- /dev/null
29921+++ b/mailpot-web/src/utils.rs
29922 @@ -0,0 +1,465 @@
29923+ /*
29924+ * This file is part of mailpot
29925+ *
29926+ * Copyright 2020 - Manos Pitsidianakis
29927+ *
29928+ * This program is free software: you can redistribute it and/or modify
29929+ * it under the terms of the GNU Affero General Public License as
29930+ * published by the Free Software Foundation, either version 3 of the
29931+ * License, or (at your option) any later version.
29932+ *
29933+ * This program is distributed in the hope that it will be useful,
29934+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
29935+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29936+ * GNU Affero General Public License for more details.
29937+ *
29938+ * You should have received a copy of the GNU Affero General Public License
29939+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
29940+ */
29941+
29942+ use super::*;
29943+
29944+ /// Navigation crumbs, e.g.: Home > Page > Subpage
29945+ ///
29946+ /// # Example
29947+ ///
29948+ /// ```rust
29949+ /// # use mailpot_web::utils::Crumb;
29950+ /// let crumbs = vec![Crumb {
29951+ /// label: "Home".into(),
29952+ /// url: "/".into(),
29953+ /// }];
29954+ /// println!("{} {}", crumbs[0].label, crumbs[0].url);
29955+ /// ```
29956+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
29957+ pub struct Crumb {
29958+ pub label: Cow<'static, str>,
29959+ #[serde(serialize_with = "to_safe_string")]
29960+ pub url: Cow<'static, str>,
29961+ }
29962+
29963+ /// Message urgency level or info.
29964+ #[derive(
29965+ Debug, Default, Hash, Copy, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq,
29966+ )]
29967+ pub enum Level {
29968+ Success,
29969+ #[default]
29970+ Info,
29971+ Warning,
29972+ Error,
29973+ }
29974+
29975+ /// UI message notifications.
29976+ #[derive(Debug, Hash, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
29977+ pub struct Message {
29978+ pub message: Cow<'static, str>,
29979+ #[serde(default)]
29980+ pub level: Level,
29981+ }
29982+
29983+ impl Message {
29984+ const MESSAGE_KEY: &'static str = "session-message";
29985+ }
29986+
29987+ /// Drain messages from session.
29988+ ///
29989+ /// # Example
29990+ ///
29991+ /// ```no_run
29992+ /// # use mailpot_web::utils::{Message, Level, SessionMessages};
29993+ /// struct Session(Vec<Message>);
29994+ ///
29995+ /// impl SessionMessages for Session {
29996+ /// type Error = std::convert::Infallible;
29997+ /// fn drain_messages(&mut self) -> Vec<Message> {
29998+ /// std::mem::take(&mut self.0)
29999+ /// }
30000+ ///
30001+ /// fn add_message(&mut self, m: Message) -> Result<(), std::convert::Infallible> {
30002+ /// self.0.push(m);
30003+ /// Ok(())
30004+ /// }
30005+ /// }
30006+ /// let mut s = Session(vec![]);
30007+ /// s.add_message(Message {
30008+ /// message: "foo".into(),
30009+ /// level: Level::default(),
30010+ /// })
30011+ /// .unwrap();
30012+ /// s.add_message(Message {
30013+ /// message: "bar".into(),
30014+ /// level: Level::Error,
30015+ /// })
30016+ /// .unwrap();
30017+ /// assert_eq!(
30018+ /// s.drain_messages().as_slice(),
30019+ /// [
30020+ /// Message {
30021+ /// message: "foo".into(),
30022+ /// level: Level::default(),
30023+ /// },
30024+ /// Message {
30025+ /// message: "bar".into(),
30026+ /// level: Level::Error
30027+ /// }
30028+ /// ]
30029+ /// .as_slice()
30030+ /// );
30031+ /// assert!(s.0.is_empty());
30032+ /// ```
30033+ pub trait SessionMessages {
30034+ type Error;
30035+
30036+ fn drain_messages(&mut self) -> Vec<Message>;
30037+ fn add_message(&mut self, _: Message) -> Result<(), Self::Error>;
30038+ }
30039+
30040+ impl SessionMessages for WritableSession {
30041+ type Error = ResponseError;
30042+
30043+ fn drain_messages(&mut self) -> Vec<Message> {
30044+ let ret = self.get(Message::MESSAGE_KEY).unwrap_or_default();
30045+ self.remove(Message::MESSAGE_KEY);
30046+ ret
30047+ }
30048+
30049+ #[allow(clippy::significant_drop_tightening)]
30050+ fn add_message(&mut self, message: Message) -> Result<(), ResponseError> {
30051+ let mut messages: Vec<Message> = self.get(Message::MESSAGE_KEY).unwrap_or_default();
30052+ messages.push(message);
30053+ self.insert(Message::MESSAGE_KEY, messages)?;
30054+ Ok(())
30055+ }
30056+ }
30057+
30058+ /// Deserialize a string integer into `i64`, because POST parameters are
30059+ /// strings.
30060+ #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Hash)]
30061+ #[repr(transparent)]
30062+ pub struct IntPOST(pub i64);
30063+
30064+ impl serde::Serialize for IntPOST {
30065+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
30066+ where
30067+ S: serde::Serializer,
30068+ {
30069+ serializer.serialize_i64(self.0)
30070+ }
30071+ }
30072+
30073+ impl<'de> serde::Deserialize<'de> for IntPOST {
30074+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
30075+ where
30076+ D: serde::Deserializer<'de>,
30077+ {
30078+ struct IntVisitor;
30079+
30080+ impl<'de> serde::de::Visitor<'de> for IntVisitor {
30081+ type Value = IntPOST;
30082+
30083+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
30084+ f.write_str("Int as a number or string")
30085+ }
30086+
30087+ fn visit_i64<E>(self, int: i64) -> Result<Self::Value, E>
30088+ where
30089+ E: serde::de::Error,
30090+ {
30091+ Ok(IntPOST(int))
30092+ }
30093+
30094+ fn visit_u64<E>(self, int: u64) -> Result<Self::Value, E>
30095+ where
30096+ E: serde::de::Error,
30097+ {
30098+ Ok(IntPOST(int.try_into().unwrap()))
30099+ }
30100+
30101+ fn visit_str<E>(self, int: &str) -> Result<Self::Value, E>
30102+ where
30103+ E: serde::de::Error,
30104+ {
30105+ int.parse().map(IntPOST).map_err(serde::de::Error::custom)
30106+ }
30107+ }
30108+
30109+ deserializer.deserialize_any(IntVisitor)
30110+ }
30111+ }
30112+
30113+ /// Deserialize a string integer into `bool`, because POST parameters are
30114+ /// strings.
30115+ #[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Hash)]
30116+ #[repr(transparent)]
30117+ pub struct BoolPOST(pub bool);
30118+
30119+ impl serde::Serialize for BoolPOST {
30120+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
30121+ where
30122+ S: serde::Serializer,
30123+ {
30124+ serializer.serialize_bool(self.0)
30125+ }
30126+ }
30127+
30128+ impl<'de> serde::Deserialize<'de> for BoolPOST {
30129+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
30130+ where
30131+ D: serde::Deserializer<'de>,
30132+ {
30133+ struct BoolVisitor;
30134+
30135+ impl<'de> serde::de::Visitor<'de> for BoolVisitor {
30136+ type Value = BoolPOST;
30137+
30138+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
30139+ f.write_str("Bool as a boolean or \"true\" \"false\"")
30140+ }
30141+
30142+ fn visit_bool<E>(self, val: bool) -> Result<Self::Value, E>
30143+ where
30144+ E: serde::de::Error,
30145+ {
30146+ Ok(BoolPOST(val))
30147+ }
30148+
30149+ fn visit_str<E>(self, val: &str) -> Result<Self::Value, E>
30150+ where
30151+ E: serde::de::Error,
30152+ {
30153+ val.parse().map(BoolPOST).map_err(serde::de::Error::custom)
30154+ }
30155+ }
30156+
30157+ deserializer.deserialize_any(BoolVisitor)
30158+ }
30159+ }
30160+
30161+ #[derive(Debug, Clone, serde::Deserialize)]
30162+ pub struct Next {
30163+ #[serde(default, deserialize_with = "empty_string_as_none")]
30164+ pub next: Option<String>,
30165+ }
30166+
30167+ impl Next {
30168+ #[inline]
30169+ pub fn or_else(self, cl: impl FnOnce() -> String) -> Redirect {
30170+ self.next
30171+ .map_or_else(|| Redirect::to(&cl()), |next| Redirect::to(&next))
30172+ }
30173+ }
30174+
30175+ /// Serde deserialization decorator to map empty Strings to None,
30176+ fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
30177+ where
30178+ D: serde::Deserializer<'de>,
30179+ T: std::str::FromStr,
30180+ T::Err: std::fmt::Display,
30181+ {
30182+ use serde::Deserialize;
30183+ let opt = Option::<String>::deserialize(de)?;
30184+ match opt.as_deref() {
30185+ None | Some("") => Ok(None),
30186+ Some(s) => std::str::FromStr::from_str(s)
30187+ .map_err(serde::de::Error::custom)
30188+ .map(Some),
30189+ }
30190+ }
30191+
30192+ /// Serialize string to [`minijinja::value::Value`] with
30193+ /// [`minijinja::value::Value::from_safe_string`].
30194+ pub fn to_safe_string<S>(s: impl AsRef<str>, ser: S) -> Result<S::Ok, S::Error>
30195+ where
30196+ S: serde::Serializer,
30197+ {
30198+ use serde::Serialize;
30199+ let s = s.as_ref();
30200+ Value::from_safe_string(s.to_string()).serialize(ser)
30201+ }
30202+
30203+ /// Serialize an optional string to [`minijinja::value::Value`] with
30204+ /// [`minijinja::value::Value::from_safe_string`].
30205+ pub fn to_safe_string_opt<S>(s: &Option<String>, ser: S) -> Result<S::Ok, S::Error>
30206+ where
30207+ S: serde::Serializer,
30208+ {
30209+ use serde::Serialize;
30210+ s.as_ref()
30211+ .map(|s| Value::from_safe_string(s.to_string()))
30212+ .serialize(ser)
30213+ }
30214+
30215+ #[derive(Debug, Clone)]
30216+ pub struct ThreadEntry {
30217+ pub hash: melib::EnvelopeHash,
30218+ pub depth: usize,
30219+ pub thread_node: melib::ThreadNodeHash,
30220+ pub thread: melib::ThreadHash,
30221+ pub from: String,
30222+ pub message_id: String,
30223+ pub timestamp: u64,
30224+ pub datetime: String,
30225+ }
30226+
30227+ pub fn thread(
30228+ envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
30229+ threads: &melib::Threads,
30230+ root_env_hash: melib::EnvelopeHash,
30231+ ) -> Vec<ThreadEntry> {
30232+ let env_lock = envelopes.read().unwrap();
30233+ let thread = threads.envelope_to_thread[&root_env_hash];
30234+ let mut ret = vec![];
30235+ for (depth, t) in threads.thread_iter(thread) {
30236+ let hash = threads.thread_nodes[&t].message.unwrap();
30237+ ret.push(ThreadEntry {
30238+ hash,
30239+ depth,
30240+ thread_node: t,
30241+ thread,
30242+ message_id: env_lock[&hash].message_id().to_string(),
30243+ from: env_lock[&hash].field_from_to_string(),
30244+ datetime: env_lock[&hash].date_as_str().to_string(),
30245+ timestamp: env_lock[&hash].timestamp,
30246+ });
30247+ }
30248+ ret
30249+ }
30250+
30251+ pub fn thread_roots(
30252+ envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
30253+ threads: &melib::Threads,
30254+ ) -> Vec<(ThreadEntry, usize, u64)> {
30255+ let items = threads.roots();
30256+ let env_lock = envelopes.read().unwrap();
30257+ let mut ret = vec![];
30258+ 'items_for_loop: for thread in items {
30259+ let mut iter_ptr = threads.thread_ref(thread).root();
30260+ let thread_node = &threads.thread_nodes()[&iter_ptr];
30261+ let root_env_hash = if let Some(h) = thread_node.message().or_else(|| {
30262+ if thread_node.children().is_empty() {
30263+ return None;
30264+ }
30265+ iter_ptr = thread_node.children()[0];
30266+ while threads.thread_nodes()[&iter_ptr].message().is_none() {
30267+ if threads.thread_nodes()[&iter_ptr].children().is_empty() {
30268+ return None;
30269+ }
30270+ iter_ptr = threads.thread_nodes()[&iter_ptr].children()[0];
30271+ }
30272+ threads.thread_nodes()[&iter_ptr].message()
30273+ }) {
30274+ h
30275+ } else {
30276+ continue 'items_for_loop;
30277+ };
30278+ if !env_lock.contains_key(&root_env_hash) {
30279+ panic!("key = {}", root_env_hash);
30280+ }
30281+ let envelope: &melib::Envelope = &env_lock[&root_env_hash];
30282+ let tref = threads.thread_ref(thread);
30283+ ret.push((
30284+ ThreadEntry {
30285+ hash: root_env_hash,
30286+ depth: 0,
30287+ thread_node: iter_ptr,
30288+ thread,
30289+ message_id: envelope.message_id().to_string(),
30290+ from: envelope.field_from_to_string(),
30291+ datetime: envelope.date_as_str().to_string(),
30292+ timestamp: envelope.timestamp,
30293+ },
30294+ tref.len,
30295+ tref.date,
30296+ ));
30297+ }
30298+ // clippy: error: temporary with significant `Drop` can be early dropped
30299+ drop(env_lock);
30300+ ret.sort_by_key(|(_, _, key)| std::cmp::Reverse(*key));
30301+ ret
30302+ }
30303+
30304+ #[cfg(test)]
30305+ mod tests {
30306+ use super::*;
30307+
30308+ #[test]
30309+ fn test_session() {
30310+ struct Session(Vec<Message>);
30311+
30312+ impl SessionMessages for Session {
30313+ type Error = std::convert::Infallible;
30314+ fn drain_messages(&mut self) -> Vec<Message> {
30315+ std::mem::take(&mut self.0)
30316+ }
30317+
30318+ fn add_message(&mut self, m: Message) -> Result<(), std::convert::Infallible> {
30319+ self.0.push(m);
30320+ Ok(())
30321+ }
30322+ }
30323+ let mut s = Session(vec![]);
30324+ s.add_message(Message {
30325+ message: "foo".into(),
30326+ level: Level::default(),
30327+ })
30328+ .unwrap();
30329+ s.add_message(Message {
30330+ message: "bar".into(),
30331+ level: Level::Error,
30332+ })
30333+ .unwrap();
30334+ assert_eq!(
30335+ s.drain_messages().as_slice(),
30336+ [
30337+ Message {
30338+ message: "foo".into(),
30339+ level: Level::default(),
30340+ },
30341+ Message {
30342+ message: "bar".into(),
30343+ level: Level::Error
30344+ }
30345+ ]
30346+ .as_slice()
30347+ );
30348+ assert!(s.0.is_empty());
30349+ }
30350+
30351+ #[test]
30352+ fn test_post_serde() {
30353+ use mailpot::serde_json::{self, json};
30354+ assert_eq!(
30355+ IntPOST(5),
30356+ serde_json::from_str::<IntPOST>("\"5\"").unwrap()
30357+ );
30358+ assert_eq!(IntPOST(5), serde_json::from_str::<IntPOST>("5").unwrap());
30359+ assert_eq!(&json! { IntPOST(5) }.to_string(), "5");
30360+
30361+ assert_eq!(
30362+ BoolPOST(true),
30363+ serde_json::from_str::<BoolPOST>("true").unwrap()
30364+ );
30365+ assert_eq!(
30366+ BoolPOST(true),
30367+ serde_json::from_str::<BoolPOST>("\"true\"").unwrap()
30368+ );
30369+ assert_eq!(&json! { BoolPOST(false) }.to_string(), "false");
30370+ }
30371+
30372+ #[test]
30373+ fn test_next() {
30374+ let next = Next {
30375+ next: Some("foo".to_string()),
30376+ };
30377+ assert_eq!(
30378+ format!("{:?}", Redirect::to("foo")),
30379+ format!("{:?}", next.or_else(|| "bar".to_string()))
30380+ );
30381+ let next = Next { next: None };
30382+ assert_eq!(
30383+ format!("{:?}", Redirect::to("bar")),
30384+ format!("{:?}", next.or_else(|| "bar".to_string()))
30385+ );
30386+ }
30387+ }
30388 diff --git a/mailpot/Cargo.toml b/mailpot/Cargo.toml
30389new file mode 100644
30390index 0000000..7e995aa
30391--- /dev/null
30392+++ b/mailpot/Cargo.toml
30393 @@ -0,0 +1,35 @@
30394+ [package]
30395+ name = "mailpot"
30396+ version = "0.1.1"
30397+ authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
30398+ edition = "2021"
30399+ license = "LICENSE"
30400+ readme = "README.md"
30401+ description = "mailing list manager"
30402+ repository = "https://github.com/meli/mailpot"
30403+ keywords = ["mail", "mailing-lists"]
30404+ categories = ["email"]
30405+
30406+ [lib]
30407+ doc-scrape-examples = true
30408+
30409+ [dependencies]
30410+ anyhow = "1.0.58"
30411+ chrono = { version = "^0.4", features = ["serde", ] }
30412+ jsonschema = { version = "0.17", default-features = false }
30413+ log = "0.4"
30414+ melib = { default-features = false, features = ["mbox", "smtp", "unicode-algorithms", "maildir"], git = "https://git.meli-email.org/meli/meli.git", rev = "64e60cb" }
30415+ minijinja = { version = "0.31.0", features = ["source", ] }
30416+ percent-encoding = { version = "^2.1" }
30417+ rusqlite = { version = "^0.30", features = ["bundled", "functions", "trace", "hooks", "serde_json", "array", "chrono", "unlock_notify"] }
30418+ serde = { version = "^1", features = ["derive", ] }
30419+ serde_json = "^1"
30420+ thiserror = { version = "1.0.48", default-features = false }
30421+ toml = "^0.5"
30422+ xdg = "2.4.1"
30423+
30424+ [dev-dependencies]
30425+ mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
30426+ reqwest = { version = "0.11", default-features = false, features = ["json", "blocking"] }
30427+ stderrlog = { version = "^0.6" }
30428+ tempfile = { version = "3.9" }
30429 diff --git a/mailpot/README.md b/mailpot/README.md
30430new file mode 100644
30431index 0000000..04d8dcf
30432--- /dev/null
30433+++ b/mailpot/README.md
30434 @@ -0,0 +1,17 @@
30435+ # mailpot-core
30436+
30437+ Initialize `sqlite3` database
30438+
30439+ ```shell
30440+ sqlite3 mpot.db < ./src/schema.sql
30441+ ```
30442+
30443+ ## Tests
30444+
30445+ `test_smtp_mailcrab` requires a running mailcrab instance.
30446+ You must set the environment variable `MAILCRAB_IP` to run this.
30447+ Example:
30448+
30449+ ```shell
30450+ MAILCRAB_IP="127.0.0.1" cargo test mailcrab
30451+ ```
30452 diff --git a/mailpot/build/make_migrations.rs b/mailpot/build/make_migrations.rs
30453new file mode 100644
30454index 0000000..91f3f2e
30455--- /dev/null
30456+++ b/mailpot/build/make_migrations.rs
30457 @@ -0,0 +1,110 @@
30458+ /*
30459+ * This file is part of mailpot
30460+ *
30461+ * Copyright 2023 - Manos Pitsidianakis
30462+ *
30463+ * This program is free software: you can redistribute it and/or modify
30464+ * it under the terms of the GNU Affero General Public License as
30465+ * published by the Free Software Foundation, either version 3 of the
30466+ * License, or (at your option) any later version.
30467+ *
30468+ * This program is distributed in the hope that it will be useful,
30469+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
30470+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
30471+ * GNU Affero General Public License for more details.
30472+ *
30473+ * You should have received a copy of the GNU Affero General Public License
30474+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
30475+ */
30476+
30477+ use std::{fs::read_dir, io::Write, path::Path};
30478+
30479+ /// Scans migrations directory for file entries, and creates a rust file with an array containing
30480+ /// the migration slices.
30481+ ///
30482+ ///
30483+ /// If a migration is a data migration (not a CREATE, DROP or ALTER statement) it is appended to
30484+ /// the schema file.
30485+ ///
30486+ /// Returns the current `user_version` PRAGMA value.
30487+ pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
30488+ migrations_path: M,
30489+ output_file: O,
30490+ schema_file: &mut Vec<u8>,
30491+ ) -> i32 {
30492+ let migrations_folder_path = migrations_path.as_ref();
30493+ let output_file_path = output_file.as_ref();
30494+
30495+ let mut paths = vec![];
30496+ let mut undo_paths = vec![];
30497+ for entry in read_dir(migrations_folder_path).unwrap() {
30498+ let entry = entry.unwrap();
30499+ let path = entry.path();
30500+ if path.is_dir() || path.extension().map(|os| os.to_str().unwrap()) != Some("sql") {
30501+ continue;
30502+ }
30503+ if path
30504+ .file_name()
30505+ .unwrap()
30506+ .to_str()
30507+ .unwrap()
30508+ .ends_with("undo.sql")
30509+ {
30510+ undo_paths.push(path);
30511+ } else {
30512+ paths.push(path);
30513+ }
30514+ }
30515+
30516+ paths.sort();
30517+ undo_paths.sort();
30518+ let mut migr_rs = OpenOptions::new()
30519+ .write(true)
30520+ .create(true)
30521+ .truncate(true)
30522+ .open(output_file_path)
30523+ .unwrap();
30524+ migr_rs
30525+ .write_all(b"\n//(user_version, redo sql, undo sql\n&[")
30526+ .unwrap();
30527+ for (i, (p, u)) in paths.iter().zip(undo_paths.iter()).enumerate() {
30528+ // This should be a number string, padded with 2 zeros if it's less than 3
30529+ // digits. e.g. 001, \d{3}
30530+ let mut num = p.file_stem().unwrap().to_str().unwrap();
30531+ let is_data = num.ends_with(".data");
30532+ if is_data {
30533+ num = num.strip_suffix(".data").unwrap();
30534+ }
30535+
30536+ if !u.file_name().unwrap().to_str().unwrap().starts_with(num) {
30537+ panic!("Undo file {u:?} should match with {p:?}");
30538+ }
30539+
30540+ if num.parse::<u32>().is_err() {
30541+ panic!("Migration file {p:?} should start with a number");
30542+ }
30543+ assert_eq!(num.parse::<usize>().unwrap(), i + 1, "migration sql files should start with 1, not zero, and no intermediate numbers should be missing. Panicked on file: {}", p.display());
30544+ migr_rs.write_all(b"(").unwrap();
30545+ migr_rs
30546+ .write_all(num.trim_start_matches('0').as_bytes())
30547+ .unwrap();
30548+ migr_rs.write_all(b",r##\"").unwrap();
30549+
30550+ let redo = std::fs::read_to_string(p).unwrap();
30551+ migr_rs.write_all(redo.trim().as_bytes()).unwrap();
30552+ migr_rs.write_all(b"\"##,r##\"").unwrap();
30553+ migr_rs
30554+ .write_all(std::fs::read_to_string(u).unwrap().trim().as_bytes())
30555+ .unwrap();
30556+ migr_rs.write_all(b"\"##),").unwrap();
30557+ if is_data {
30558+ schema_file.extend(b"\n\n-- ".iter());
30559+ schema_file.extend(num.as_bytes().iter());
30560+ schema_file.extend(b".data.sql\n\n".iter());
30561+ schema_file.extend(redo.into_bytes().into_iter());
30562+ }
30563+ }
30564+ migr_rs.write_all(b"]").unwrap();
30565+ migr_rs.flush().unwrap();
30566+ paths.len() as i32
30567+ }
30568 diff --git a/mailpot/build/mod.rs b/mailpot/build/mod.rs
30569new file mode 100644
30570index 0000000..44e41d2
30571--- /dev/null
30572+++ b/mailpot/build/mod.rs
30573 @@ -0,0 +1,95 @@
30574+ /*
30575+ * This file is part of mailpot
30576+ *
30577+ * Copyright 2020 - Manos Pitsidianakis
30578+ *
30579+ * This program is free software: you can redistribute it and/or modify
30580+ * it under the terms of the GNU Affero General Public License as
30581+ * published by the Free Software Foundation, either version 3 of the
30582+ * License, or (at your option) any later version.
30583+ *
30584+ * This program is distributed in the hope that it will be useful,
30585+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
30586+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
30587+ * GNU Affero General Public License for more details.
30588+ *
30589+ * You should have received a copy of the GNU Affero General Public License
30590+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
30591+ */
30592+
30593+ use std::{
30594+ fs::OpenOptions,
30595+ process::{Command, Stdio},
30596+ };
30597+
30598+ // // Source: https://stackoverflow.com/a/64535181
30599+ // fn is_output_file_outdated<P1, P2>(input: P1, output: P2) -> io::Result<bool>
30600+ // where
30601+ // P1: AsRef<Path>,
30602+ // P2: AsRef<Path>,
30603+ // {
30604+ // let out_meta = metadata(output);
30605+ // if let Ok(meta) = out_meta {
30606+ // let output_mtime = meta.modified()?;
30607+ //
30608+ // // if input file is more recent than our output, we are outdated
30609+ // let input_meta = metadata(input)?;
30610+ // let input_mtime = input_meta.modified()?;
30611+ //
30612+ // Ok(input_mtime > output_mtime)
30613+ // } else {
30614+ // // output file not found, we are outdated
30615+ // Ok(true)
30616+ // }
30617+ // }
30618+
30619+ include!("make_migrations.rs");
30620+
30621+ const MIGRATION_RS: &str = "src/migrations.rs.inc";
30622+
30623+ fn main() {
30624+ println!("cargo:rerun-if-changed=src/migrations.rs.inc");
30625+ println!("cargo:rerun-if-changed=migrations");
30626+ println!("cargo:rerun-if-changed=src/schema.sql.m4");
30627+
30628+ let mut output = Command::new("m4")
30629+ .arg("./src/schema.sql.m4")
30630+ .output()
30631+ .unwrap();
30632+ if String::from_utf8_lossy(&output.stdout).trim().is_empty() {
30633+ panic!(
30634+ "m4 output is empty. stderr was {}",
30635+ String::from_utf8_lossy(&output.stderr)
30636+ );
30637+ }
30638+ let user_version: i32 = make_migrations("migrations", MIGRATION_RS, &mut output.stdout);
30639+ let mut verify = Command::new(std::env::var("SQLITE_BIN").unwrap_or("sqlite3".into()))
30640+ .stdin(Stdio::piped())
30641+ .stdout(Stdio::piped())
30642+ .stderr(Stdio::piped())
30643+ .spawn()
30644+ .unwrap();
30645+ println!(
30646+ "Verifying by creating an in-memory database in sqlite3 and feeding it the output schema."
30647+ );
30648+ verify
30649+ .stdin
30650+ .take()
30651+ .unwrap()
30652+ .write_all(&output.stdout)
30653+ .unwrap();
30654+ let exit = verify.wait_with_output().unwrap();
30655+ if !exit.status.success() {
30656+ panic!(
30657+ "sqlite3 could not read SQL schema: {}",
30658+ String::from_utf8_lossy(&exit.stdout)
30659+ );
30660+ }
30661+ let mut file = std::fs::File::create("./src/schema.sql").unwrap();
30662+ file.write_all(&output.stdout).unwrap();
30663+ file.write_all(
30664+ &format!("\n\n-- Set current schema version.\n\nPRAGMA user_version = {user_version};\n")
30665+ .as_bytes(),
30666+ )
30667+ .unwrap();
30668+ }
30669 diff --git a/mailpot/create_migration.py b/mailpot/create_migration.py
30670new file mode 100644
30671index 0000000..a4b3318
30672--- /dev/null
30673+++ b/mailpot/create_migration.py
30674 @@ -0,0 +1,87 @@
30675+ import json
30676+ from pathlib import Path
30677+ import re
30678+ import sys
30679+ import pprint
30680+ import argparse
30681+
30682+
30683+ def make_undo(id: str) -> str:
30684+ return f"DELETE FROM settings_json_schema WHERE id = '{id}';"
30685+
30686+
30687+ def make_redo(id: str, value: str) -> str:
30688+ return f"""INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('{id}', '{value}');"""
30689+
30690+
30691+ class Migration:
30692+ patt = re.compile(r"(\d+)[.].*sql")
30693+
30694+ def __init__(self, path: Path):
30695+ name = path.name
30696+ self.path = path
30697+ self.is_data = "data" in name
30698+ self.is_undo = "undo" in name
30699+ m = self.patt.match(name)
30700+ self.seq = int(m.group(1))
30701+ self.name = name
30702+
30703+ def __str__(self) -> str:
30704+ return str(self.seq)
30705+
30706+ def __repr__(self) -> str:
30707+ return f"Migration(seq={self.seq},name={self.name},path={self.path},is_data={self.is_data},is_undo={self.is_undo})"
30708+
30709+
30710+ if __name__ == "__main__":
30711+ parser = argparse.ArgumentParser(
30712+ prog="Create migrations", description="", epilog=""
30713+ )
30714+ parser.add_argument("--data", action="store_true")
30715+ parser.add_argument("--settings", action="store_true")
30716+ parser.add_argument("--name", type=str, default=None)
30717+ parser.add_argument("--dry-run", action="store_true")
30718+ args = parser.parse_args()
30719+ migrations = {}
30720+ last = -1
30721+ for f in Path(".").glob("migrations/*.sql"):
30722+ m = Migration(f)
30723+ last = max(last, m.seq)
30724+ seq = str(m)
30725+ if seq not in migrations:
30726+ if m.is_undo:
30727+ migrations[seq] = (None, m)
30728+ else:
30729+ migrations[seq] = (m, None)
30730+ else:
30731+ if m.is_undo:
30732+ redo, _ = migrations[seq]
30733+ migrations[seq] = (redo, m)
30734+ else:
30735+ _, undo = migrations[seq]
30736+ migrations[seq] = (m, undo)
30737+ # pprint.pprint(migrations)
30738+ if args.data:
30739+ data = ".data"
30740+ else:
30741+ data = ""
30742+ new_name = f"{last+1:0>3}{data}.sql"
30743+ new_undo_name = f"{last+1:0>3}{data}.undo.sql"
30744+ if not args.dry_run:
30745+ redo = ""
30746+ undo = ""
30747+ if args.settings:
30748+ if not args.name:
30749+ print("Please define a --name.")
30750+ sys.exit(1)
30751+ redo = make_redo(args.name, "{}")
30752+ undo = make_undo(args.name)
30753+ name = args.name.lower() + ".json"
30754+ with open(Path("settings_json_schemas") / name, "x") as file:
30755+ file.write("{}")
30756+ with open(Path("migrations") / new_name, "x") as file, open(
30757+ Path("migrations") / new_undo_name, "x"
30758+ ) as undo_file:
30759+ file.write(redo)
30760+ undo_file.write(undo)
30761+ print(f"Created to {new_name} and {new_undo_name}.")
30762 diff --git a/mailpot/migrations/001.sql b/mailpot/migrations/001.sql
30763new file mode 100644
30764index 0000000..345a376
30765--- /dev/null
30766+++ b/mailpot/migrations/001.sql
30767 @@ -0,0 +1,2 @@
30768+ PRAGMA foreign_keys=ON;
30769+ ALTER TABLE templates RENAME TO template;
30770 diff --git a/mailpot/migrations/001.undo.sql b/mailpot/migrations/001.undo.sql
30771new file mode 100644
30772index 0000000..e0e03fb
30773--- /dev/null
30774+++ b/mailpot/migrations/001.undo.sql
30775 @@ -0,0 +1,2 @@
30776+ PRAGMA foreign_keys=ON;
30777+ ALTER TABLE template RENAME TO templates;
30778 diff --git a/mailpot/migrations/002.sql b/mailpot/migrations/002.sql
30779new file mode 100644
30780index 0000000..7dbb83a
30781--- /dev/null
30782+++ b/mailpot/migrations/002.sql
30783 @@ -0,0 +1,2 @@
30784+ PRAGMA foreign_keys=ON;
30785+ ALTER TABLE list ADD COLUMN topics JSON NOT NULL CHECK (json_type(topics) == 'array') DEFAULT '[]';
30786 diff --git a/mailpot/migrations/002.undo.sql b/mailpot/migrations/002.undo.sql
30787new file mode 100644
30788index 0000000..9a18755
30789--- /dev/null
30790+++ b/mailpot/migrations/002.undo.sql
30791 @@ -0,0 +1,2 @@
30792+ PRAGMA foreign_keys=ON;
30793+ ALTER TABLE list DROP COLUMN topics;
30794 diff --git a/mailpot/migrations/003.sql b/mailpot/migrations/003.sql
30795new file mode 100644
30796index 0000000..039c720
30797--- /dev/null
30798+++ b/mailpot/migrations/003.sql
30799 @@ -0,0 +1,20 @@
30800+ PRAGMA foreign_keys=ON;
30801+
30802+ UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk;
30803+
30804+ CREATE TRIGGER
30805+ IF NOT EXISTS sort_topics_update_trigger
30806+ AFTER UPDATE ON list
30807+ FOR EACH ROW
30808+ WHEN NEW.topics != OLD.topics
30809+ BEGIN
30810+ UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
30811+ END;
30812+
30813+ CREATE TRIGGER
30814+ IF NOT EXISTS sort_topics_new_trigger
30815+ AFTER INSERT ON list
30816+ FOR EACH ROW
30817+ BEGIN
30818+ UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
30819+ END;
30820 diff --git a/mailpot/migrations/003.undo.sql b/mailpot/migrations/003.undo.sql
30821new file mode 100644
30822index 0000000..f6c7d9a
30823--- /dev/null
30824+++ b/mailpot/migrations/003.undo.sql
30825 @@ -0,0 +1,4 @@
30826+ PRAGMA foreign_keys=ON;
30827+
30828+ DROP TRIGGER sort_topics_update_trigger;
30829+ DROP TRIGGER sort_topics_new_trigger;
30830 diff --git a/mailpot/migrations/004.sql b/mailpot/migrations/004.sql
30831new file mode 100644
30832index 0000000..95aff47
30833--- /dev/null
30834+++ b/mailpot/migrations/004.sql
30835 @@ -0,0 +1,167 @@
30836+ CREATE TABLE IF NOT EXISTS settings_json_schema (
30837+ pk INTEGER PRIMARY KEY NOT NULL,
30838+ id TEXT NOT NULL UNIQUE,
30839+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
30840+ created INTEGER NOT NULL DEFAULT (unixepoch()),
30841+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
30842+ );
30843+
30844+ CREATE TABLE IF NOT EXISTS list_settings_json (
30845+ pk INTEGER PRIMARY KEY NOT NULL,
30846+ name TEXT NOT NULL,
30847+ list INTEGER,
30848+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
30849+ is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
30850+ created INTEGER NOT NULL DEFAULT (unixepoch()),
30851+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
30852+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
30853+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
30854+ UNIQUE (list, name) ON CONFLICT ROLLBACK
30855+ );
30856+
30857+ CREATE TRIGGER
30858+ IF NOT EXISTS is_valid_settings_json_on_update
30859+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
30860+ FOR EACH ROW
30861+ BEGIN
30862+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
30863+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
30864+ END;
30865+
30866+ CREATE TRIGGER
30867+ IF NOT EXISTS is_valid_settings_json_on_insert
30868+ AFTER INSERT ON list_settings_json
30869+ FOR EACH ROW
30870+ BEGIN
30871+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
30872+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
30873+ END;
30874+
30875+ CREATE TRIGGER
30876+ IF NOT EXISTS invalidate_settings_json_on_schema_update
30877+ AFTER UPDATE OF value, id ON settings_json_schema
30878+ FOR EACH ROW
30879+ BEGIN
30880+ UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
30881+ END;
30882+
30883+ DROP TRIGGER IF EXISTS last_modified_list;
30884+ DROP TRIGGER IF EXISTS last_modified_owner;
30885+ DROP TRIGGER IF EXISTS last_modified_post_policy;
30886+ DROP TRIGGER IF EXISTS last_modified_subscription_policy;
30887+ DROP TRIGGER IF EXISTS last_modified_subscription;
30888+ DROP TRIGGER IF EXISTS last_modified_account;
30889+ DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
30890+ DROP TRIGGER IF EXISTS last_modified_template;
30891+ DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
30892+ DROP TRIGGER IF EXISTS last_modified_list_settings_json;
30893+
30894+ -- [tag:last_modified_list]: update last_modified on every change.
30895+ CREATE TRIGGER
30896+ IF NOT EXISTS last_modified_list
30897+ AFTER UPDATE ON list
30898+ FOR EACH ROW
30899+ WHEN NEW.last_modified == OLD.last_modified
30900+ BEGIN
30901+ UPDATE list SET last_modified = unixepoch()
30902+ WHERE pk = NEW.pk;
30903+ END;
30904+
30905+ -- [tag:last_modified_owner]: update last_modified on every change.
30906+ CREATE TRIGGER
30907+ IF NOT EXISTS last_modified_owner
30908+ AFTER UPDATE ON owner
30909+ FOR EACH ROW
30910+ WHEN NEW.last_modified == OLD.last_modified
30911+ BEGIN
30912+ UPDATE owner SET last_modified = unixepoch()
30913+ WHERE pk = NEW.pk;
30914+ END;
30915+
30916+ -- [tag:last_modified_post_policy]: update last_modified on every change.
30917+ CREATE TRIGGER
30918+ IF NOT EXISTS last_modified_post_policy
30919+ AFTER UPDATE ON post_policy
30920+ FOR EACH ROW
30921+ WHEN NEW.last_modified == OLD.last_modified
30922+ BEGIN
30923+ UPDATE post_policy SET last_modified = unixepoch()
30924+ WHERE pk = NEW.pk;
30925+ END;
30926+
30927+ -- [tag:last_modified_subscription_policy]: update last_modified on every change.
30928+ CREATE TRIGGER
30929+ IF NOT EXISTS last_modified_subscription_policy
30930+ AFTER UPDATE ON subscription_policy
30931+ FOR EACH ROW
30932+ WHEN NEW.last_modified == OLD.last_modified
30933+ BEGIN
30934+ UPDATE subscription_policy SET last_modified = unixepoch()
30935+ WHERE pk = NEW.pk;
30936+ END;
30937+
30938+ -- [tag:last_modified_subscription]: update last_modified on every change.
30939+ CREATE TRIGGER
30940+ IF NOT EXISTS last_modified_subscription
30941+ AFTER UPDATE ON subscription
30942+ FOR EACH ROW
30943+ WHEN NEW.last_modified == OLD.last_modified
30944+ BEGIN
30945+ UPDATE subscription SET last_modified = unixepoch()
30946+ WHERE pk = NEW.pk;
30947+ END;
30948+
30949+ -- [tag:last_modified_account]: update last_modified on every change.
30950+ CREATE TRIGGER
30951+ IF NOT EXISTS last_modified_account
30952+ AFTER UPDATE ON account
30953+ FOR EACH ROW
30954+ WHEN NEW.last_modified == OLD.last_modified
30955+ BEGIN
30956+ UPDATE account SET last_modified = unixepoch()
30957+ WHERE pk = NEW.pk;
30958+ END;
30959+
30960+ -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
30961+ CREATE TRIGGER
30962+ IF NOT EXISTS last_modified_candidate_subscription
30963+ AFTER UPDATE ON candidate_subscription
30964+ FOR EACH ROW
30965+ WHEN NEW.last_modified == OLD.last_modified
30966+ BEGIN
30967+ UPDATE candidate_subscription SET last_modified = unixepoch()
30968+ WHERE pk = NEW.pk;
30969+ END;
30970+
30971+ -- [tag:last_modified_template]: update last_modified on every change.
30972+ CREATE TRIGGER
30973+ IF NOT EXISTS last_modified_template
30974+ AFTER UPDATE ON template
30975+ FOR EACH ROW
30976+ WHEN NEW.last_modified == OLD.last_modified
30977+ BEGIN
30978+ UPDATE template SET last_modified = unixepoch()
30979+ WHERE pk = NEW.pk;
30980+ END;
30981+
30982+ -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
30983+ CREATE TRIGGER
30984+ IF NOT EXISTS last_modified_settings_json_schema
30985+ AFTER UPDATE ON settings_json_schema
30986+ FOR EACH ROW
30987+ WHEN NEW.last_modified == OLD.last_modified
30988+ BEGIN
30989+ UPDATE settings_json_schema SET last_modified = unixepoch()
30990+ WHERE pk = NEW.pk;
30991+ END;
30992+
30993+ -- [tag:last_modified_list_settings_json]: update last_modified on every change.
30994+ CREATE TRIGGER
30995+ IF NOT EXISTS last_modified_list_settings_json
30996+ AFTER UPDATE ON list_settings_json
30997+ FOR EACH ROW
30998+ WHEN NEW.last_modified == OLD.last_modified
30999+ BEGIN
31000+ UPDATE list_settings_json SET last_modified = unixepoch()
31001+ WHERE pk = NEW.pk;
31002+ END;
31003 diff --git a/mailpot/migrations/004.undo.sql b/mailpot/migrations/004.undo.sql
31004new file mode 100644
31005index 0000000..b780b5c
31006--- /dev/null
31007+++ b/mailpot/migrations/004.undo.sql
31008 @@ -0,0 +1,2 @@
31009+ DROP TABLE settings_json_schema;
31010+ DROP TABLE list_settings_json;
31011 diff --git a/mailpot/migrations/005.data.sql b/mailpot/migrations/005.data.sql
31012new file mode 100644
31013index 0000000..af28922
31014--- /dev/null
31015+++ b/mailpot/migrations/005.data.sql
31016 @@ -0,0 +1,31 @@
31017+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
31018+ "$schema": "http://json-schema.org/draft-07/schema",
31019+ "$ref": "#/$defs/ArchivedAtLinkSettings",
31020+ "$defs": {
31021+ "ArchivedAtLinkSettings": {
31022+ "title": "ArchivedAtLinkSettings",
31023+ "description": "Settings for ArchivedAtLink message filter",
31024+ "type": "object",
31025+ "properties": {
31026+ "template": {
31027+ "title": "Jinja template for header value",
31028+ "description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
31029+ "examples": [
31030+ "https://www.example.com/{{msg_id}}",
31031+ "https://www.example.com/{{msg_id}}.html"
31032+ ],
31033+ "type": "string",
31034+ "pattern": ".+[{][{]msg_id[}][}].*"
31035+ },
31036+ "preserve_carets": {
31037+ "title": "Preserve carets of `Message-ID` in generated value",
31038+ "type": "boolean",
31039+ "default": false
31040+ }
31041+ },
31042+ "required": [
31043+ "template"
31044+ ]
31045+ }
31046+ }
31047+ }');
31048 diff --git a/mailpot/migrations/005.data.undo.sql b/mailpot/migrations/005.data.undo.sql
31049new file mode 100644
31050index 0000000..952d321
31051--- /dev/null
31052+++ b/mailpot/migrations/005.data.undo.sql
31053 @@ -0,0 +1 @@
31054+ DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';
31055 diff --git a/mailpot/migrations/006.data.sql b/mailpot/migrations/006.data.sql
31056new file mode 100644
31057index 0000000..a5741e0
31058--- /dev/null
31059+++ b/mailpot/migrations/006.data.sql
31060 @@ -0,0 +1,20 @@
31061+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
31062+ "$schema": "http://json-schema.org/draft-07/schema",
31063+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
31064+ "$defs": {
31065+ "AddSubjectTagPrefixSettings": {
31066+ "title": "AddSubjectTagPrefixSettings",
31067+ "description": "Settings for AddSubjectTagPrefix message filter",
31068+ "type": "object",
31069+ "properties": {
31070+ "enabled": {
31071+ "title": "If true, the list subject prefix is added to post subjects.",
31072+ "type": "boolean"
31073+ }
31074+ },
31075+ "required": [
31076+ "enabled"
31077+ ]
31078+ }
31079+ }
31080+ }');
31081 diff --git a/mailpot/migrations/006.data.undo.sql b/mailpot/migrations/006.data.undo.sql
31082new file mode 100644
31083index 0000000..a805e53
31084--- /dev/null
31085+++ b/mailpot/migrations/006.data.undo.sql
31086 @@ -0,0 +1 @@
31087+ DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';
31088 diff --git a/mailpot/migrations/007.data.sql b/mailpot/migrations/007.data.sql
31089new file mode 100644
31090index 0000000..c1bbfc2
31091--- /dev/null
31092+++ b/mailpot/migrations/007.data.sql
31093 @@ -0,0 +1,33 @@
31094+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
31095+ "$schema": "http://json-schema.org/draft-07/schema",
31096+ "$ref": "#/$defs/MimeRejectSettings",
31097+ "$defs": {
31098+ "MimeRejectSettings": {
31099+ "title": "MimeRejectSettings",
31100+ "description": "Settings for MimeReject message filter",
31101+ "type": "object",
31102+ "properties": {
31103+ "enabled": {
31104+ "title": "If true, list posts that contain mime types in the reject array are rejected.",
31105+ "type": "boolean"
31106+ },
31107+ "reject": {
31108+ "title": "Mime types to reject.",
31109+ "type": "array",
31110+ "minLength": 0,
31111+ "items": { "$ref": "#/$defs/MimeType" }
31112+ },
31113+ "required": [
31114+ "enabled"
31115+ ]
31116+ }
31117+ },
31118+ "MimeType": {
31119+ "type": "string",
31120+ "maxLength": 127,
31121+ "minLength": 3,
31122+ "uniqueItems": true,
31123+ "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
31124+ }
31125+ }
31126+ }');
31127 diff --git a/mailpot/migrations/007.data.undo.sql b/mailpot/migrations/007.data.undo.sql
31128new file mode 100644
31129index 0000000..cfd0945
31130--- /dev/null
31131+++ b/mailpot/migrations/007.data.undo.sql
31132 @@ -0,0 +1 @@
31133+ DELETE FROM settings_json_schema WHERE id = 'MimeRejectSettings';
31134\ No newline at end of file
31135 diff --git a/mailpot/rustfmt.toml b/mailpot/rustfmt.toml
31136new file mode 120000
31137index 0000000..39f97b0
31138--- /dev/null
31139+++ b/mailpot/rustfmt.toml
31140 @@ -0,0 +1 @@
31141+ ../rustfmt.toml
31142\ No newline at end of file
31143 diff --git a/mailpot/settings_json_schemas/addsubjecttagprefix.json b/mailpot/settings_json_schemas/addsubjecttagprefix.json
31144new file mode 100644
31145index 0000000..4556b2b
31146--- /dev/null
31147+++ b/mailpot/settings_json_schemas/addsubjecttagprefix.json
31148 @@ -0,0 +1,20 @@
31149+ {
31150+ "$schema": "http://json-schema.org/draft-07/schema",
31151+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
31152+ "$defs": {
31153+ "AddSubjectTagPrefixSettings": {
31154+ "title": "AddSubjectTagPrefixSettings",
31155+ "description": "Settings for AddSubjectTagPrefix message filter",
31156+ "type": "object",
31157+ "properties": {
31158+ "enabled": {
31159+ "title": "If true, the list subject prefix is added to post subjects.",
31160+ "type": "boolean"
31161+ }
31162+ },
31163+ "required": [
31164+ "enabled"
31165+ ]
31166+ }
31167+ }
31168+ }
31169 diff --git a/mailpot/settings_json_schemas/archivedatlink.json b/mailpot/settings_json_schemas/archivedatlink.json
31170new file mode 100644
31171index 0000000..2b832fe
31172--- /dev/null
31173+++ b/mailpot/settings_json_schemas/archivedatlink.json
31174 @@ -0,0 +1,31 @@
31175+ {
31176+ "$schema": "http://json-schema.org/draft-07/schema",
31177+ "$ref": "#/$defs/ArchivedAtLinkSettings",
31178+ "$defs": {
31179+ "ArchivedAtLinkSettings": {
31180+ "title": "ArchivedAtLinkSettings",
31181+ "description": "Settings for ArchivedAtLink message filter",
31182+ "type": "object",
31183+ "properties": {
31184+ "template": {
31185+ "title": "Jinja template for header value",
31186+ "description": "Template for `Archived-At` header value, as described in RFC 5064 \"The Archived-At Message Header Field\". The template receives only one string variable with the value of the mailing list post `Message-ID` header.\n\nFor example, if:\n\n- the template is `http://www.example.com/mid/{{msg_id}}`\n- the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\nThe full header will be generated as:\n\n`Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\nNote: Surrounding carets in the `Message-ID` value are not required. If you wish to preserve them in the URL, set option `preserve-carets` to true.",
31187+ "examples": [
31188+ "https://www.example.com/{{msg_id}}",
31189+ "https://www.example.com/{{msg_id}}.html"
31190+ ],
31191+ "type": "string",
31192+ "pattern": ".+[{][{]msg_id[}][}].*"
31193+ },
31194+ "preserve_carets": {
31195+ "title": "Preserve carets of `Message-ID` in generated value",
31196+ "type": "boolean",
31197+ "default": false
31198+ }
31199+ },
31200+ "required": [
31201+ "template"
31202+ ]
31203+ }
31204+ }
31205+ }
31206 diff --git a/mailpot/settings_json_schemas/mimerejectsettings.json b/mailpot/settings_json_schemas/mimerejectsettings.json
31207new file mode 100644
31208index 0000000..5bd0511
31209--- /dev/null
31210+++ b/mailpot/settings_json_schemas/mimerejectsettings.json
31211 @@ -0,0 +1,33 @@
31212+ {
31213+ "$schema": "http://json-schema.org/draft-07/schema",
31214+ "$ref": "#/$defs/MimeRejectSettings",
31215+ "$defs": {
31216+ "MimeRejectSettings": {
31217+ "title": "MimeRejectSettings",
31218+ "description": "Settings for MimeReject message filter",
31219+ "type": "object",
31220+ "properties": {
31221+ "enabled": {
31222+ "title": "If true, list posts that contain mime types in the reject array are rejected.",
31223+ "type": "boolean"
31224+ },
31225+ "reject": {
31226+ "title": "Mime types to reject.",
31227+ "type": "array",
31228+ "minLength": 0,
31229+ "items": { "$ref": "#/$defs/MimeType" }
31230+ },
31231+ "required": [
31232+ "enabled"
31233+ ]
31234+ }
31235+ },
31236+ "MimeType": {
31237+ "type": "string",
31238+ "maxLength": 127,
31239+ "minLength": 3,
31240+ "uniqueItems": true,
31241+ "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
31242+ }
31243+ }
31244+ }
31245 diff --git a/mailpot/src/config.rs b/mailpot/src/config.rs
31246new file mode 100644
31247index 0000000..ef2ab16
31248--- /dev/null
31249+++ b/mailpot/src/config.rs
31250 @@ -0,0 +1,167 @@
31251+ /*
31252+ * This file is part of mailpot
31253+ *
31254+ * Copyright 2020 - Manos Pitsidianakis
31255+ *
31256+ * This program is free software: you can redistribute it and/or modify
31257+ * it under the terms of the GNU Affero General Public License as
31258+ * published by the Free Software Foundation, either version 3 of the
31259+ * License, or (at your option) any later version.
31260+ *
31261+ * This program is distributed in the hope that it will be useful,
31262+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
31263+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
31264+ * GNU Affero General Public License for more details.
31265+ *
31266+ * You should have received a copy of the GNU Affero General Public License
31267+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
31268+ */
31269+
31270+ use std::{
31271+ io::{Read, Write},
31272+ os::unix::fs::PermissionsExt,
31273+ path::{Path, PathBuf},
31274+ };
31275+
31276+ use chrono::prelude::*;
31277+
31278+ use super::errors::*;
31279+
31280+ /// How to send e-mail.
31281+ #[derive(Debug, Serialize, Deserialize, Clone)]
31282+ #[serde(tag = "type", content = "value")]
31283+ pub enum SendMail {
31284+ /// A `melib` configuration for talking to an SMTP server.
31285+ Smtp(melib::smtp::SmtpServerConf),
31286+ /// A plain shell command passed to `sh -c` with the e-mail passed in the
31287+ /// stdin.
31288+ ShellCommand(String),
31289+ }
31290+
31291+ /// The configuration for the mailpot database and the mail server.
31292+ #[derive(Debug, Serialize, Deserialize, Clone)]
31293+ pub struct Configuration {
31294+ /// How to send e-mail.
31295+ pub send_mail: SendMail,
31296+ /// The location of the sqlite3 file.
31297+ pub db_path: PathBuf,
31298+ /// The directory where data are stored.
31299+ pub data_path: PathBuf,
31300+ /// Instance administrators (List of e-mail addresses). Optional.
31301+ #[serde(default)]
31302+ pub administrators: Vec<String>,
31303+ }
31304+
31305+ impl Configuration {
31306+ /// Create a new configuration value from a given database path value.
31307+ ///
31308+ /// If you wish to create a new database with this configuration, use
31309+ /// [`Connection::open_or_create_db`](crate::Connection::open_or_create_db).
31310+ /// To open an existing database, use
31311+ /// [`Database::open_db`](crate::Connection::open_db).
31312+ pub fn new(db_path: impl Into<PathBuf>) -> Self {
31313+ let db_path = db_path.into();
31314+ Self {
31315+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
31316+ data_path: db_path
31317+ .parent()
31318+ .map(Path::to_path_buf)
31319+ .unwrap_or_else(|| db_path.clone()),
31320+ administrators: vec![],
31321+ db_path,
31322+ }
31323+ }
31324+
31325+ /// Deserialize configuration from TOML file.
31326+ pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
31327+ let path = path.as_ref();
31328+ let mut s = String::new();
31329+ let mut file = std::fs::File::open(path)
31330+ .with_context(|| format!("Configuration file {} not found.", path.display()))?;
31331+ file.read_to_string(&mut s)
31332+ .with_context(|| format!("Could not read from file {}.", path.display()))?;
31333+ let config: Self = toml::from_str(&s)
31334+ .map_err(anyhow::Error::from)
31335+ .with_context(|| {
31336+ format!(
31337+ "Could not parse configuration file `{}` successfully: ",
31338+ path.display()
31339+ )
31340+ })?;
31341+
31342+ Ok(config)
31343+ }
31344+
31345+ /// The saved data path.
31346+ pub fn data_directory(&self) -> &Path {
31347+ self.data_path.as_path()
31348+ }
31349+
31350+ /// The sqlite3 database path.
31351+ pub fn db_path(&self) -> &Path {
31352+ self.db_path.as_path()
31353+ }
31354+
31355+ /// Save message to a custom path.
31356+ pub fn save_message_to_path(&self, msg: &str, mut path: PathBuf) -> Result<PathBuf> {
31357+ if path.is_dir() {
31358+ let now = Local::now().timestamp();
31359+ path.push(format!("{}-failed.eml", now));
31360+ }
31361+
31362+ debug_assert!(path != self.db_path());
31363+ let mut file = std::fs::File::create(&path)
31364+ .with_context(|| format!("Could not create file {}.", path.display()))?;
31365+ let metadata = file
31366+ .metadata()
31367+ .with_context(|| format!("Could not fstat file {}.", path.display()))?;
31368+ let mut permissions = metadata.permissions();
31369+
31370+ permissions.set_mode(0o600); // Read/write for owner only.
31371+ file.set_permissions(permissions)
31372+ .with_context(|| format!("Could not chmod 600 file {}.", path.display()))?;
31373+ file.write_all(msg.as_bytes())
31374+ .with_context(|| format!("Could not write message to file {}.", path.display()))?;
31375+ file.flush()
31376+ .with_context(|| format!("Could not flush message I/O to file {}.", path.display()))?;
31377+ Ok(path)
31378+ }
31379+
31380+ /// Save message to the data directory.
31381+ pub fn save_message(&self, msg: String) -> Result<PathBuf> {
31382+ self.save_message_to_path(&msg, self.data_directory().to_path_buf())
31383+ }
31384+
31385+ /// Serialize configuration to a TOML string.
31386+ pub fn to_toml(&self) -> String {
31387+ toml::Value::try_from(self)
31388+ .expect("Could not serialize config to TOML")
31389+ .to_string()
31390+ }
31391+ }
31392+
31393+ #[cfg(test)]
31394+ mod tests {
31395+ use tempfile::TempDir;
31396+
31397+ use super::*;
31398+
31399+ #[test]
31400+ fn test_config_parse_error() {
31401+ let tmp_dir = TempDir::new().unwrap();
31402+ let conf_path = tmp_dir.path().join("conf.toml");
31403+ std::fs::write(&conf_path, b"afjsad skas as a as\n\n\n\n\t\x11\n").unwrap();
31404+
31405+ assert_eq!(
31406+ Configuration::from_file(&conf_path)
31407+ .unwrap_err()
31408+ .display_chain()
31409+ .to_string(),
31410+ format!(
31411+ "[1] Could not parse configuration file `{}` successfully: Caused by:\n[2] \
31412+ Error: expected an equals, found an identifier at line 1 column 8\n",
31413+ conf_path.display()
31414+ ),
31415+ );
31416+ }
31417+ }
31418 diff --git a/mailpot/src/connection.rs b/mailpot/src/connection.rs
31419new file mode 100644
31420index 0000000..5f122eb
31421--- /dev/null
31422+++ b/mailpot/src/connection.rs
31423 @@ -0,0 +1,1381 @@
31424+ /*
31425+ * This file is part of mailpot
31426+ *
31427+ * Copyright 2020 - Manos Pitsidianakis
31428+ *
31429+ * This program is free software: you can redistribute it and/or modify
31430+ * it under the terms of the GNU Affero General Public License as
31431+ * published by the Free Software Foundation, either version 3 of the
31432+ * License, or (at your option) any later version.
31433+ *
31434+ * This program is distributed in the hope that it will be useful,
31435+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
31436+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
31437+ * GNU Affero General Public License for more details.
31438+ *
31439+ * You should have received a copy of the GNU Affero General Public License
31440+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
31441+ */
31442+
31443+ //! Mailpot database and methods.
31444+
31445+ use std::{
31446+ io::Write,
31447+ process::{Command, Stdio},
31448+ };
31449+
31450+ use jsonschema::JSONSchema;
31451+ use log::{info, trace};
31452+ use rusqlite::{functions::FunctionFlags, Connection as DbConnection, OptionalExtension};
31453+
31454+ use crate::{
31455+ config::Configuration,
31456+ errors::{ErrorKind::*, *},
31457+ models::{changesets::MailingListChangeset, DbVal, ListOwner, MailingList, Post},
31458+ StripCarets,
31459+ };
31460+
31461+ /// A connection to a `mailpot` database.
31462+ pub struct Connection {
31463+ /// The `rusqlite` connection handle.
31464+ pub connection: DbConnection,
31465+ pub(crate) conf: Configuration,
31466+ }
31467+
31468+ impl std::fmt::Debug for Connection {
31469+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
31470+ fmt.debug_struct("Connection")
31471+ .field("conf", &self.conf)
31472+ .finish()
31473+ }
31474+ }
31475+
31476+ impl Drop for Connection {
31477+ fn drop(&mut self) {
31478+ self.connection
31479+ .authorizer::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>(
31480+ None,
31481+ );
31482+ // make sure pragma optimize does not take too long
31483+ _ = self.connection.pragma_update(None, "analysis_limit", "400");
31484+ // gather statistics to improve query optimization
31485+ _ = self
31486+ .connection
31487+ .pragma(None, "optimize", 0xfffe_i64, |_| Ok(()));
31488+ }
31489+ }
31490+
31491+ fn log_callback(error_code: std::ffi::c_int, message: &str) {
31492+ match error_code {
31493+ rusqlite::ffi::SQLITE_NOTICE => log::trace!("{}", message),
31494+ rusqlite::ffi::SQLITE_OK
31495+ | rusqlite::ffi::SQLITE_DONE
31496+ | rusqlite::ffi::SQLITE_NOTICE_RECOVER_WAL
31497+ | rusqlite::ffi::SQLITE_NOTICE_RECOVER_ROLLBACK => log::info!("{}", message),
31498+ rusqlite::ffi::SQLITE_WARNING | rusqlite::ffi::SQLITE_WARNING_AUTOINDEX => {
31499+ log::warn!("{}", message)
31500+ }
31501+ _ => log::error!("{error_code} {}", message),
31502+ }
31503+ }
31504+
31505+ fn user_authorizer_callback(
31506+ auth_context: rusqlite::hooks::AuthContext<'_>,
31507+ ) -> rusqlite::hooks::Authorization {
31508+ use rusqlite::hooks::{AuthAction, Authorization};
31509+
31510+ // [ref:sync_auth_doc] sync with `untrusted()` rustdoc when changing this.
31511+ match auth_context.action {
31512+ AuthAction::Delete {
31513+ table_name: "queue" | "candidate_subscription" | "subscription",
31514+ }
31515+ | AuthAction::Insert {
31516+ table_name: "post" | "queue" | "candidate_subscription" | "subscription" | "account",
31517+ }
31518+ | AuthAction::Update {
31519+ table_name: "candidate_subscription" | "template",
31520+ column_name: "accepted" | "last_modified" | "verified" | "address",
31521+ }
31522+ | AuthAction::Update {
31523+ table_name: "account",
31524+ column_name: "last_modified" | "name" | "public_key" | "password",
31525+ }
31526+ | AuthAction::Update {
31527+ table_name: "subscription",
31528+ column_name:
31529+ "last_modified"
31530+ | "account"
31531+ | "digest"
31532+ | "verified"
31533+ | "hide_address"
31534+ | "receive_duplicates"
31535+ | "receive_own_posts"
31536+ | "receive_confirmation",
31537+ }
31538+ | AuthAction::Select
31539+ | AuthAction::Savepoint { .. }
31540+ | AuthAction::Transaction { .. }
31541+ | AuthAction::Read { .. }
31542+ | AuthAction::Function {
31543+ function_name: "count" | "strftime" | "unixepoch" | "datetime",
31544+ } => Authorization::Allow,
31545+ _ => Authorization::Deny,
31546+ }
31547+ }
31548+
31549+ impl Connection {
31550+ /// The database schema.
31551+ ///
31552+ /// ```sql
31553+ #[doc = include_str!("./schema.sql")]
31554+ /// ```
31555+ pub const SCHEMA: &'static str = include_str!("./schema.sql");
31556+
31557+ /// Database migrations.
31558+ pub const MIGRATIONS: &'static [(u32, &'static str, &'static str)] =
31559+ include!("./migrations.rs.inc");
31560+
31561+ /// Creates a new database connection.
31562+ ///
31563+ /// `Connection` supports a limited subset of operations by default (see
31564+ /// [`Connection::untrusted`]).
31565+ /// Use [`Connection::trusted`] to remove these limits.
31566+ ///
31567+ /// # Example
31568+ ///
31569+ /// ```rust,no_run
31570+ /// use mailpot::{Connection, Configuration};
31571+ /// use melib::smtp::{SmtpServerConf, SmtpAuth, SmtpSecurity};
31572+ /// #
31573+ /// # fn main() -> mailpot::Result<()> {
31574+ /// # use tempfile::TempDir;
31575+ /// #
31576+ /// # let tmp_dir = TempDir::new()?;
31577+ /// # let db_path = tmp_dir.path().join("mpot.db");
31578+ /// # let data_path = tmp_dir.path().to_path_buf();
31579+ /// let config = Configuration {
31580+ /// send_mail: mailpot::SendMail::Smtp(
31581+ /// SmtpServerConf {
31582+ /// hostname: "127.0.0.1".into(),
31583+ /// port: 25,
31584+ /// envelope_from: "foo-chat@example.com".into(),
31585+ /// auth: SmtpAuth::None,
31586+ /// security: SmtpSecurity::None,
31587+ /// extensions: Default::default(),
31588+ /// }
31589+ /// ),
31590+ /// db_path,
31591+ /// data_path,
31592+ /// administrators: vec![],
31593+ /// };
31594+ /// # assert_eq!(&Connection::open_db(config.clone()).unwrap_err().to_string(), "Database doesn't exist");
31595+ ///
31596+ /// let db = Connection::open_or_create_db(config)?;
31597+ /// # _ = db;
31598+ /// # Ok(())
31599+ /// # }
31600+ /// ```
31601+ pub fn open_db(conf: Configuration) -> Result<Self> {
31602+ use std::sync::Once;
31603+
31604+ use rusqlite::config::DbConfig;
31605+
31606+ static INIT_SQLITE_LOGGING: Once = Once::new();
31607+
31608+ if !conf.db_path.exists() {
31609+ return Err("Database doesn't exist".into());
31610+ }
31611+ INIT_SQLITE_LOGGING.call_once(|| {
31612+ _ = unsafe { rusqlite::trace::config_log(Some(log_callback)) };
31613+ });
31614+ let conn = DbConnection::open(conf.db_path.to_str().unwrap()).with_context(|| {
31615+ format!("sqlite3 library could not open {}.", conf.db_path.display())
31616+ })?;
31617+ rusqlite::vtab::array::load_module(&conn)?;
31618+ conn.pragma_update(None, "journal_mode", "WAL")?;
31619+ conn.pragma_update(None, "foreign_keys", "on")?;
31620+ // synchronise less often to the filesystem
31621+ conn.pragma_update(None, "synchronous", "normal")?;
31622+ conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_FKEY, true)?;
31623+ conn.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)?;
31624+ conn.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
31625+ conn.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, true)?;
31626+ conn.busy_timeout(core::time::Duration::from_millis(500))?;
31627+ conn.busy_handler(Some(|times: i32| -> bool { times < 5 }))?;
31628+ conn.create_scalar_function(
31629+ "validate_json_schema",
31630+ 2,
31631+ FunctionFlags::SQLITE_INNOCUOUS
31632+ | FunctionFlags::SQLITE_UTF8
31633+ | FunctionFlags::SQLITE_DETERMINISTIC,
31634+ |ctx| {
31635+ if log::log_enabled!(log::Level::Trace) {
31636+ rusqlite::trace::log(
31637+ rusqlite::ffi::SQLITE_NOTICE,
31638+ "validate_json_schema RUNNING",
31639+ );
31640+ }
31641+ let map_err = rusqlite::Error::UserFunctionError;
31642+ let schema = ctx.get::<String>(0)?;
31643+ let value = ctx.get::<String>(1)?;
31644+ let schema_val: serde_json::Value = serde_json::from_str(&schema)
31645+ .map_err(Into::into)
31646+ .map_err(map_err)?;
31647+ let value: serde_json::Value = serde_json::from_str(&value)
31648+ .map_err(Into::into)
31649+ .map_err(map_err)?;
31650+ let compiled = JSONSchema::compile(&schema_val)
31651+ .map_err(|err| err.to_string())
31652+ .map_err(Into::into)
31653+ .map_err(map_err)?;
31654+ let x = if let Err(errors) = compiled.validate(&value) {
31655+ for err in errors {
31656+ rusqlite::trace::log(rusqlite::ffi::SQLITE_WARNING, &err.to_string());
31657+ drop(err);
31658+ }
31659+ Ok(false)
31660+ } else {
31661+ Ok(true)
31662+ };
31663+ x
31664+ },
31665+ )?;
31666+
31667+ let ret = Self {
31668+ conf,
31669+ connection: conn,
31670+ };
31671+ if let Some(&(latest, _, _)) = Self::MIGRATIONS.last() {
31672+ let version = ret.schema_version()?;
31673+ trace!(
31674+ "SQLITE user_version PRAGMA returned {version}. Most recent migration is {latest}."
31675+ );
31676+ if version < latest {
31677+ info!("Updating database schema from version {version} to {latest}...");
31678+ }
31679+ ret.migrate(version, latest)?;
31680+ }
31681+
31682+ ret.connection.authorizer(Some(user_authorizer_callback));
31683+ Ok(ret)
31684+ }
31685+
31686+ /// The version of the current schema.
31687+ pub fn schema_version(&self) -> Result<u32> {
31688+ Ok(self
31689+ .connection
31690+ .prepare("SELECT user_version FROM pragma_user_version;")?
31691+ .query_row([], |row| {
31692+ let v: u32 = row.get(0)?;
31693+ Ok(v)
31694+ })?)
31695+ }
31696+
31697+ /// Migrate from version `from` to `to`.
31698+ ///
31699+ /// See [Self::MIGRATIONS].
31700+ pub fn migrate(&self, mut from: u32, to: u32) -> Result<()> {
31701+ if from == to {
31702+ return Ok(());
31703+ }
31704+
31705+ let undo = from > to;
31706+ let tx = self.savepoint(Some(stringify!(migrate)))?;
31707+
31708+ while from != to {
31709+ log::trace!(
31710+ "exec migration from {from} to {to}, type: {}do",
31711+ if undo { "un " } else { "re" }
31712+ );
31713+ if undo {
31714+ trace!("{}", Self::MIGRATIONS[from as usize - 1].2);
31715+ tx.connection
31716+ .execute_batch(Self::MIGRATIONS[from as usize - 1].2)?;
31717+ from -= 1;
31718+ } else {
31719+ trace!("{}", Self::MIGRATIONS[from as usize].1);
31720+ tx.connection
31721+ .execute_batch(Self::MIGRATIONS[from as usize].1)?;
31722+ from += 1;
31723+ }
31724+ }
31725+ tx.connection
31726+ .pragma_update(None, "user_version", Self::MIGRATIONS[to as usize - 1].0)?;
31727+
31728+ tx.commit()?;
31729+
31730+ Ok(())
31731+ }
31732+
31733+ /// Removes operational limits from this connection. (see
31734+ /// [`Connection::untrusted`])
31735+ #[must_use]
31736+ pub fn trusted(self) -> Self {
31737+ self.connection
31738+ .authorizer::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>(
31739+ None,
31740+ );
31741+ self
31742+ }
31743+
31744+ // [tag:sync_auth_doc]
31745+ /// Sets operational limits for this connection.
31746+ ///
31747+ /// - Allow `INSERT`, `DELETE` only for "queue", "candidate_subscription",
31748+ /// "subscription".
31749+ /// - Allow `UPDATE` only for "subscription" user facing settings.
31750+ /// - Allow `INSERT` only for "post".
31751+ /// - Allow read access to all tables.
31752+ /// - Allow `SELECT`, `TRANSACTION`, `SAVEPOINT`, and the `strftime`
31753+ /// function.
31754+ /// - Deny everything else.
31755+ pub fn untrusted(self) -> Self {
31756+ self.connection.authorizer(Some(user_authorizer_callback));
31757+ self
31758+ }
31759+
31760+ /// Create a database if it doesn't exist and then open it.
31761+ pub fn open_or_create_db(conf: Configuration) -> Result<Self> {
31762+ if !conf.db_path.exists() {
31763+ let db_path = &conf.db_path;
31764+ use std::os::unix::fs::PermissionsExt;
31765+
31766+ info!("Creating database in {}", db_path.display());
31767+ std::fs::File::create(db_path).context("Could not create db path")?;
31768+
31769+ let mut child =
31770+ Command::new(std::env::var("SQLITE_BIN").unwrap_or_else(|_| "sqlite3".into()))
31771+ .arg(db_path)
31772+ .stdin(Stdio::piped())
31773+ .stdout(Stdio::piped())
31774+ .stderr(Stdio::piped())
31775+ .spawn()
31776+ .with_context(|| {
31777+ format!(
31778+ "Could not launch {} {}.",
31779+ std::env::var("SQLITE_BIN").unwrap_or_else(|_| "sqlite3".into()),
31780+ db_path.display()
31781+ )
31782+ })?;
31783+ let mut stdin = child.stdin.take().unwrap();
31784+ std::thread::spawn(move || {
31785+ stdin
31786+ .write_all(Self::SCHEMA.as_bytes())
31787+ .expect("failed to write to stdin");
31788+ if !Self::MIGRATIONS.is_empty() {
31789+ stdin
31790+ .write_all(b"\nPRAGMA user_version = ")
31791+ .expect("failed to write to stdin");
31792+ stdin
31793+ .write_all(
31794+ Self::MIGRATIONS[Self::MIGRATIONS.len() - 1]
31795+ .0
31796+ .to_string()
31797+ .as_bytes(),
31798+ )
31799+ .expect("failed to write to stdin");
31800+ stdin.write_all(b";").expect("failed to write to stdin");
31801+ }
31802+ stdin.flush().expect("could not flush stdin");
31803+ });
31804+ let output = child.wait_with_output()?;
31805+ if !output.status.success() {
31806+ return Err(format!(
31807+ "Could not initialize sqlite3 database at {}: sqlite3 returned exit code {} \
31808+ and stderr {} {}",
31809+ db_path.display(),
31810+ output.status.code().unwrap_or_default(),
31811+ String::from_utf8_lossy(&output.stderr),
31812+ String::from_utf8_lossy(&output.stdout)
31813+ )
31814+ .into());
31815+ }
31816+
31817+ let file = std::fs::File::open(db_path)
31818+ .with_context(|| format!("Could not open database {}.", db_path.display()))?;
31819+ let metadata = file
31820+ .metadata()
31821+ .with_context(|| format!("Could not fstat database {}.", db_path.display()))?;
31822+ let mut permissions = metadata.permissions();
31823+
31824+ permissions.set_mode(0o600); // Read/write for owner only.
31825+ file.set_permissions(permissions)
31826+ .with_context(|| format!("Could not chmod 600 database {}.", db_path.display()))?;
31827+ }
31828+ Self::open_db(conf)
31829+ }
31830+
31831+ /// Returns a connection's configuration.
31832+ pub fn conf(&self) -> &Configuration {
31833+ &self.conf
31834+ }
31835+
31836+ /// Loads archive databases from [`Configuration::data_path`], if any.
31837+ pub fn load_archives(&self) -> Result<()> {
31838+ let tx = self.savepoint(Some(stringify!(load_archives)))?;
31839+ {
31840+ let mut stmt = tx.connection.prepare("ATTACH ? AS ?;")?;
31841+ for archive in std::fs::read_dir(&self.conf.data_path)? {
31842+ let archive = archive?;
31843+ let path = archive.path();
31844+ let name = path.file_name().unwrap_or_default();
31845+ if path == self.conf.db_path {
31846+ continue;
31847+ }
31848+ stmt.execute(rusqlite::params![
31849+ path.to_str().unwrap(),
31850+ name.to_str().unwrap()
31851+ ])?;
31852+ }
31853+ }
31854+ tx.commit()?;
31855+
31856+ Ok(())
31857+ }
31858+
31859+ /// Returns a vector of existing mailing lists.
31860+ pub fn lists(&self) -> Result<Vec<DbVal<MailingList>>> {
31861+ let mut stmt = self.connection.prepare("SELECT * FROM list;")?;
31862+ let list_iter = stmt.query_map([], |row| {
31863+ let pk = row.get("pk")?;
31864+ let topics: serde_json::Value = row.get("topics")?;
31865+ let topics = MailingList::topics_from_json_value(topics)?;
31866+ Ok(DbVal(
31867+ MailingList {
31868+ pk,
31869+ name: row.get("name")?,
31870+ id: row.get("id")?,
31871+ address: row.get("address")?,
31872+ description: row.get("description")?,
31873+ topics,
31874+ archive_url: row.get("archive_url")?,
31875+ },
31876+ pk,
31877+ ))
31878+ })?;
31879+
31880+ let mut ret = vec![];
31881+ for list in list_iter {
31882+ let list = list?;
31883+ ret.push(list);
31884+ }
31885+ Ok(ret)
31886+ }
31887+
31888+ /// Fetch a mailing list by primary key.
31889+ pub fn list(&self, pk: i64) -> Result<Option<DbVal<MailingList>>> {
31890+ let mut stmt = self
31891+ .connection
31892+ .prepare("SELECT * FROM list WHERE pk = ?;")?;
31893+ let ret = stmt
31894+ .query_row([&pk], |row| {
31895+ let pk = row.get("pk")?;
31896+ let topics: serde_json::Value = row.get("topics")?;
31897+ let topics = MailingList::topics_from_json_value(topics)?;
31898+ Ok(DbVal(
31899+ MailingList {
31900+ pk,
31901+ name: row.get("name")?,
31902+ id: row.get("id")?,
31903+ address: row.get("address")?,
31904+ description: row.get("description")?,
31905+ topics,
31906+ archive_url: row.get("archive_url")?,
31907+ },
31908+ pk,
31909+ ))
31910+ })
31911+ .optional()?;
31912+ Ok(ret)
31913+ }
31914+
31915+ /// Fetch a mailing list by id.
31916+ pub fn list_by_id<S: AsRef<str>>(&self, id: S) -> Result<Option<DbVal<MailingList>>> {
31917+ let id = id.as_ref();
31918+ let mut stmt = self
31919+ .connection
31920+ .prepare("SELECT * FROM list WHERE id = ?;")?;
31921+ let ret = stmt
31922+ .query_row([&id], |row| {
31923+ let pk = row.get("pk")?;
31924+ let topics: serde_json::Value = row.get("topics")?;
31925+ let topics = MailingList::topics_from_json_value(topics)?;
31926+ Ok(DbVal(
31927+ MailingList {
31928+ pk,
31929+ name: row.get("name")?,
31930+ id: row.get("id")?,
31931+ address: row.get("address")?,
31932+ description: row.get("description")?,
31933+ topics,
31934+ archive_url: row.get("archive_url")?,
31935+ },
31936+ pk,
31937+ ))
31938+ })
31939+ .optional()?;
31940+
31941+ Ok(ret)
31942+ }
31943+
31944+ /// Create a new list.
31945+ pub fn create_list(&self, new_val: MailingList) -> Result<DbVal<MailingList>> {
31946+ let mut stmt = self.connection.prepare(
31947+ "INSERT INTO list(name, id, address, description, archive_url, topics) VALUES(?, ?, \
31948+ ?, ?, ?, ?) RETURNING *;",
31949+ )?;
31950+ let ret = stmt.query_row(
31951+ rusqlite::params![
31952+ &new_val.name,
31953+ &new_val.id,
31954+ &new_val.address,
31955+ new_val.description.as_ref(),
31956+ new_val.archive_url.as_ref(),
31957+ serde_json::json!(new_val.topics.as_slice()),
31958+ ],
31959+ |row| {
31960+ let pk = row.get("pk")?;
31961+ let topics: serde_json::Value = row.get("topics")?;
31962+ let topics = MailingList::topics_from_json_value(topics)?;
31963+ Ok(DbVal(
31964+ MailingList {
31965+ pk,
31966+ name: row.get("name")?,
31967+ id: row.get("id")?,
31968+ address: row.get("address")?,
31969+ description: row.get("description")?,
31970+ topics,
31971+ archive_url: row.get("archive_url")?,
31972+ },
31973+ pk,
31974+ ))
31975+ },
31976+ )?;
31977+
31978+ trace!("create_list {:?}.", &ret);
31979+ Ok(ret)
31980+ }
31981+
31982+ /// Fetch all posts of a mailing list.
31983+ pub fn list_posts(
31984+ &self,
31985+ list_pk: i64,
31986+ _date_range: Option<(String, String)>,
31987+ ) -> Result<Vec<DbVal<Post>>> {
31988+ let mut stmt = self.connection.prepare(
31989+ "SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
31990+ FROM post WHERE list = ? ORDER BY timestamp ASC;",
31991+ )?;
31992+ let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
31993+ let pk = row.get("pk")?;
31994+ Ok(DbVal(
31995+ Post {
31996+ pk,
31997+ list: row.get("list")?,
31998+ envelope_from: row.get("envelope_from")?,
31999+ address: row.get("address")?,
32000+ message_id: row.get("message_id")?,
32001+ message: row.get("message")?,
32002+ timestamp: row.get("timestamp")?,
32003+ datetime: row.get("datetime")?,
32004+ month_year: row.get("month_year")?,
32005+ },
32006+ pk,
32007+ ))
32008+ })?;
32009+ let mut ret = vec![];
32010+ for post in iter {
32011+ let post = post?;
32012+ ret.push(post);
32013+ }
32014+
32015+ trace!("list_posts {:?}.", &ret);
32016+ Ok(ret)
32017+ }
32018+
32019+ /// Fetch the contents of a single thread in the form of `(depth, post)`
32020+ /// where `depth` is the reply distance between a message and the thread
32021+ /// root message.
32022+ pub fn list_thread(&self, list_pk: i64, root: &str) -> Result<Vec<(i64, DbVal<Post>)>> {
32023+ let mut stmt = self
32024+ .connection
32025+ .prepare(
32026+ "WITH RECURSIVE cte_replies AS MATERIALIZED
32027+ (
32028+ SELECT
32029+ pk,
32030+ message_id,
32031+ REPLACE(
32032+ TRIM(
32033+ SUBSTR(
32034+ CAST(message AS TEXT),
32035+ INSTR(
32036+ CAST(message AS TEXT),
32037+ 'In-Reply-To: '
32038+ )
32039+ +
32040+ LENGTH('in-reply-to: '),
32041+ INSTR(
32042+ SUBSTR(
32043+ CAST(message AS TEXT),
32044+ INSTR(
32045+ CAST(message AS TEXT),
32046+ 'In-Reply-To: ')
32047+ +
32048+ LENGTH('in-reply-to: ')
32049+ ),
32050+ '>'
32051+ )
32052+ )
32053+ ),
32054+ ' ',
32055+ ''
32056+ ) AS in_reply_to,
32057+ INSTR(
32058+ CAST(message AS TEXT),
32059+ 'In-Reply-To: '
32060+ ) AS offset
32061+ FROM post
32062+ WHERE
32063+ offset > 0
32064+ UNION
32065+ SELECT
32066+ pk,
32067+ message_id,
32068+ NULL AS in_reply_to,
32069+ INSTR(
32070+ CAST(message AS TEXT),
32071+ 'In-Reply-To: '
32072+ ) AS offset
32073+ FROM post
32074+ WHERE
32075+ offset = 0
32076+ ),
32077+ cte_thread(parent, root, depth) AS (
32078+ SELECT DISTINCT
32079+ message_id AS parent,
32080+ message_id AS root,
32081+ 0 AS depth
32082+ FROM cte_replies
32083+ WHERE
32084+ in_reply_to IS NULL
32085+ UNION ALL
32086+ SELECT
32087+ t.message_id AS parent,
32088+ cte_thread.root AS root,
32089+ (cte_thread.depth + 1) AS depth
32090+ FROM cte_replies
32091+ AS t
32092+ JOIN
32093+ cte_thread
32094+ ON cte_thread.parent = t.in_reply_to
32095+ WHERE t.in_reply_to IS NOT NULL
32096+ )
32097+ SELECT * FROM cte_thread WHERE root = ? ORDER BY root, depth;",
32098+ )
32099+ .unwrap();
32100+ let iter = stmt.query_map(rusqlite::params![root], |row| {
32101+ let parent: String = row.get("parent")?;
32102+ let root: String = row.get("root")?;
32103+ let depth: i64 = row.get("depth")?;
32104+ Ok((parent, root, depth))
32105+ })?;
32106+ let mut ret = vec![];
32107+ for post in iter {
32108+ ret.push(post?);
32109+ }
32110+ let posts = self.list_posts(list_pk, None)?;
32111+ let ret = ret
32112+ .into_iter()
32113+ .filter_map(|(m, _, depth)| {
32114+ posts
32115+ .iter()
32116+ .find(|p| m.as_str().strip_carets() == p.message_id.as_str().strip_carets())
32117+ .map(|p| (depth, p.clone()))
32118+ })
32119+ .skip(1)
32120+ .collect();
32121+ Ok(ret)
32122+ }
32123+
32124+ /// Export a list, message, or thread in mbox format
32125+ pub fn export_mbox(
32126+ &self,
32127+ pk: i64,
32128+ message_id: Option<&str>,
32129+ as_thread: bool,
32130+ ) -> Result<Vec<u8>> {
32131+ let posts: Result<Vec<DbVal<Post>>> = {
32132+ if let Some(message_id) = message_id {
32133+ if as_thread {
32134+ // export a thread
32135+ let thread = self.list_thread(pk, message_id)?;
32136+ Ok(thread.iter().map(|item| item.1.clone()).collect())
32137+ } else {
32138+ // export a single message
32139+ let message =
32140+ self.list_post_by_message_id(pk, message_id)?
32141+ .ok_or_else(|| {
32142+ Error::from(format!("no message with id: {}", message_id))
32143+ })?;
32144+ Ok(vec![message])
32145+ }
32146+ } else {
32147+ // export the entire mailing list
32148+ let posts = self.list_posts(pk, None)?;
32149+ Ok(posts)
32150+ }
32151+ };
32152+ let mut buf: Vec<u8> = Vec::new();
32153+ let mailbox = melib::mbox::MboxFormat::default();
32154+ for post in posts? {
32155+ let envelope_from = if let Some(address) = post.0.envelope_from {
32156+ let address = melib::Address::try_from(address.as_str())?;
32157+ Some(address)
32158+ } else {
32159+ None
32160+ };
32161+ let envelope = melib::Envelope::from_bytes(&post.0.message, None)?;
32162+ mailbox.append(
32163+ &mut buf,
32164+ &post.0.message.to_vec(),
32165+ envelope_from.as_ref(),
32166+ Some(envelope.timestamp),
32167+ (melib::Flag::PASSED, vec![]),
32168+ melib::mbox::MboxMetadata::None,
32169+ false,
32170+ false,
32171+ )?;
32172+ }
32173+ buf.flush()?;
32174+ Ok(buf)
32175+ }
32176+
32177+ /// Fetch the owners of a mailing list.
32178+ pub fn list_owners(&self, pk: i64) -> Result<Vec<DbVal<ListOwner>>> {
32179+ let mut stmt = self
32180+ .connection
32181+ .prepare("SELECT * FROM owner WHERE list = ?;")?;
32182+ let list_iter = stmt.query_map([&pk], |row| {
32183+ let pk = row.get("pk")?;
32184+ Ok(DbVal(
32185+ ListOwner {
32186+ pk,
32187+ list: row.get("list")?,
32188+ address: row.get("address")?,
32189+ name: row.get("name")?,
32190+ },
32191+ pk,
32192+ ))
32193+ })?;
32194+
32195+ let mut ret = vec![];
32196+ for list in list_iter {
32197+ let list = list?;
32198+ ret.push(list);
32199+ }
32200+ Ok(ret)
32201+ }
32202+
32203+ /// Remove an owner of a mailing list.
32204+ pub fn remove_list_owner(&self, list_pk: i64, owner_pk: i64) -> Result<()> {
32205+ self.connection
32206+ .query_row(
32207+ "DELETE FROM owner WHERE list = ? AND pk = ? RETURNING *;",
32208+ rusqlite::params![&list_pk, &owner_pk],
32209+ |_| Ok(()),
32210+ )
32211+ .map_err(|err| {
32212+ if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
32213+ Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
32214+ } else {
32215+ Error::from(err)
32216+ }
32217+ })?;
32218+ Ok(())
32219+ }
32220+
32221+ /// Add an owner of a mailing list.
32222+ pub fn add_list_owner(&self, list_owner: ListOwner) -> Result<DbVal<ListOwner>> {
32223+ let mut stmt = self.connection.prepare(
32224+ "INSERT OR REPLACE INTO owner(list, address, name) VALUES (?, ?, ?) RETURNING *;",
32225+ )?;
32226+ let list_pk = list_owner.list;
32227+ let ret = stmt
32228+ .query_row(
32229+ rusqlite::params![&list_pk, &list_owner.address, &list_owner.name,],
32230+ |row| {
32231+ let pk = row.get("pk")?;
32232+ Ok(DbVal(
32233+ ListOwner {
32234+ pk,
32235+ list: row.get("list")?,
32236+ address: row.get("address")?,
32237+ name: row.get("name")?,
32238+ },
32239+ pk,
32240+ ))
32241+ },
32242+ )
32243+ .map_err(|err| {
32244+ if matches!(
32245+ err,
32246+ rusqlite::Error::SqliteFailure(
32247+ rusqlite::ffi::Error {
32248+ code: rusqlite::ffi::ErrorCode::ConstraintViolation,
32249+ extended_code: 787
32250+ },
32251+ _
32252+ )
32253+ ) {
32254+ Error::from(err).chain_err(|| NotFound("Could not find a list with this pk."))
32255+ } else {
32256+ err.into()
32257+ }
32258+ })?;
32259+
32260+ trace!("add_list_owner {:?}.", &ret);
32261+ Ok(ret)
32262+ }
32263+
32264+ /// Update a mailing list.
32265+ pub fn update_list(&self, change_set: MailingListChangeset) -> Result<()> {
32266+ if matches!(
32267+ change_set,
32268+ MailingListChangeset {
32269+ pk: _,
32270+ name: None,
32271+ id: None,
32272+ address: None,
32273+ description: None,
32274+ archive_url: None,
32275+ owner_local_part: None,
32276+ request_local_part: None,
32277+ verify: None,
32278+ hidden: None,
32279+ enabled: None,
32280+ }
32281+ ) {
32282+ return self.list(change_set.pk).map(|_| ());
32283+ }
32284+
32285+ let MailingListChangeset {
32286+ pk,
32287+ name,
32288+ id,
32289+ address,
32290+ description,
32291+ archive_url,
32292+ owner_local_part,
32293+ request_local_part,
32294+ verify,
32295+ hidden,
32296+ enabled,
32297+ } = change_set;
32298+ let tx = self.savepoint(Some(stringify!(update_list)))?;
32299+
32300+ macro_rules! update {
32301+ ($field:tt) => {{
32302+ if let Some($field) = $field {
32303+ tx.connection.execute(
32304+ concat!("UPDATE list SET ", stringify!($field), " = ? WHERE pk = ?;"),
32305+ rusqlite::params![&$field, &pk],
32306+ )?;
32307+ }
32308+ }};
32309+ }
32310+ update!(name);
32311+ update!(id);
32312+ update!(address);
32313+ update!(description);
32314+ update!(archive_url);
32315+ update!(owner_local_part);
32316+ update!(request_local_part);
32317+ update!(verify);
32318+ update!(hidden);
32319+ update!(enabled);
32320+
32321+ tx.commit()?;
32322+ Ok(())
32323+ }
32324+
32325+ /// Execute operations inside an SQL transaction.
32326+ pub fn transaction(
32327+ &'_ mut self,
32328+ behavior: transaction::TransactionBehavior,
32329+ ) -> Result<transaction::Transaction<'_>> {
32330+ use transaction::*;
32331+
32332+ let query = match behavior {
32333+ TransactionBehavior::Deferred => "BEGIN DEFERRED",
32334+ TransactionBehavior::Immediate => "BEGIN IMMEDIATE",
32335+ TransactionBehavior::Exclusive => "BEGIN EXCLUSIVE",
32336+ };
32337+ self.connection.execute_batch(query)?;
32338+ Ok(Transaction {
32339+ conn: self,
32340+ drop_behavior: DropBehavior::Rollback,
32341+ })
32342+ }
32343+
32344+ /// Execute operations inside an SQL savepoint.
32345+ pub fn savepoint(&'_ self, name: Option<&'static str>) -> Result<transaction::Savepoint<'_>> {
32346+ use std::sync::atomic::{AtomicUsize, Ordering};
32347+
32348+ use transaction::*;
32349+ static COUNTER: AtomicUsize = AtomicUsize::new(0);
32350+
32351+ let name = name
32352+ .map(Ok)
32353+ .unwrap_or_else(|| Err(COUNTER.fetch_add(1, Ordering::Relaxed)));
32354+
32355+ match name {
32356+ Ok(ref n) => self.connection.execute_batch(&format!("SAVEPOINT {n}"))?,
32357+ Err(ref i) => self.connection.execute_batch(&format!("SAVEPOINT _{i}"))?,
32358+ };
32359+
32360+ Ok(Savepoint {
32361+ conn: self,
32362+ drop_behavior: DropBehavior::Rollback,
32363+ name,
32364+ committed: false,
32365+ })
32366+ }
32367+ }
32368+
32369+ /// Execute operations inside an SQL transaction.
32370+ pub mod transaction {
32371+ use super::*;
32372+
32373+ /// A transaction handle.
32374+ #[derive(Debug)]
32375+ pub struct Transaction<'conn> {
32376+ pub(super) conn: &'conn mut Connection,
32377+ pub(super) drop_behavior: DropBehavior,
32378+ }
32379+
32380+ impl Drop for Transaction<'_> {
32381+ fn drop(&mut self) {
32382+ _ = self.finish_();
32383+ }
32384+ }
32385+
32386+ impl Transaction<'_> {
32387+ /// Commit and consume transaction.
32388+ pub fn commit(mut self) -> Result<()> {
32389+ self.commit_()
32390+ }
32391+
32392+ fn commit_(&mut self) -> Result<()> {
32393+ self.conn.connection.execute_batch("COMMIT")?;
32394+ Ok(())
32395+ }
32396+
32397+ /// Configure the transaction to perform the specified action when it is
32398+ /// dropped.
32399+ #[inline]
32400+ pub fn set_drop_behavior(&mut self, drop_behavior: DropBehavior) {
32401+ self.drop_behavior = drop_behavior;
32402+ }
32403+
32404+ /// A convenience method which consumes and rolls back a transaction.
32405+ #[inline]
32406+ pub fn rollback(mut self) -> Result<()> {
32407+ self.rollback_()
32408+ }
32409+
32410+ fn rollback_(&mut self) -> Result<()> {
32411+ self.conn.connection.execute_batch("ROLLBACK")?;
32412+ Ok(())
32413+ }
32414+
32415+ /// Consumes the transaction, committing or rolling back according to
32416+ /// the current setting (see `drop_behavior`).
32417+ ///
32418+ /// Functionally equivalent to the `Drop` implementation, but allows
32419+ /// callers to see any errors that occur.
32420+ #[inline]
32421+ pub fn finish(mut self) -> Result<()> {
32422+ self.finish_()
32423+ }
32424+
32425+ #[inline]
32426+ fn finish_(&mut self) -> Result<()> {
32427+ if self.conn.connection.is_autocommit() {
32428+ return Ok(());
32429+ }
32430+ match self.drop_behavior {
32431+ DropBehavior::Commit => self.commit_().or_else(|_| self.rollback_()),
32432+ DropBehavior::Rollback => self.rollback_(),
32433+ DropBehavior::Ignore => Ok(()),
32434+ DropBehavior::Panic => panic!("Transaction dropped unexpectedly."),
32435+ }
32436+ }
32437+ }
32438+
32439+ impl std::ops::Deref for Transaction<'_> {
32440+ type Target = Connection;
32441+
32442+ #[inline]
32443+ fn deref(&self) -> &Connection {
32444+ self.conn
32445+ }
32446+ }
32447+
32448+ /// Options for transaction behavior. See [BEGIN
32449+ /// TRANSACTION](http://www.sqlite.org/lang_transaction.html) for details.
32450+ #[derive(Copy, Clone, Default)]
32451+ #[non_exhaustive]
32452+ pub enum TransactionBehavior {
32453+ /// DEFERRED means that the transaction does not actually start until
32454+ /// the database is first accessed.
32455+ Deferred,
32456+ #[default]
32457+ /// IMMEDIATE cause the database connection to start a new write
32458+ /// immediately, without waiting for a writes statement.
32459+ Immediate,
32460+ /// EXCLUSIVE prevents other database connections from reading the
32461+ /// database while the transaction is underway.
32462+ Exclusive,
32463+ }
32464+
32465+ /// Options for how a Transaction or Savepoint should behave when it is
32466+ /// dropped.
32467+ #[derive(Default, Copy, Clone, Debug, PartialEq, Eq)]
32468+ #[non_exhaustive]
32469+ pub enum DropBehavior {
32470+ #[default]
32471+ /// Roll back the changes. This is the default.
32472+ Rollback,
32473+
32474+ /// Commit the changes.
32475+ Commit,
32476+
32477+ /// Do not commit or roll back changes - this will leave the transaction
32478+ /// or savepoint open, so should be used with care.
32479+ Ignore,
32480+
32481+ /// Panic. Used to enforce intentional behavior during development.
32482+ Panic,
32483+ }
32484+
32485+ /// A savepoint handle.
32486+ #[derive(Debug)]
32487+ pub struct Savepoint<'conn> {
32488+ pub(super) conn: &'conn Connection,
32489+ pub(super) drop_behavior: DropBehavior,
32490+ pub(super) name: std::result::Result<&'static str, usize>,
32491+ pub(super) committed: bool,
32492+ }
32493+
32494+ impl Drop for Savepoint<'_> {
32495+ fn drop(&mut self) {
32496+ _ = self.finish_();
32497+ }
32498+ }
32499+
32500+ impl Savepoint<'_> {
32501+ /// Commit and consume savepoint.
32502+ pub fn commit(mut self) -> Result<()> {
32503+ self.commit_()
32504+ }
32505+
32506+ fn commit_(&mut self) -> Result<()> {
32507+ if !self.committed {
32508+ match self.name {
32509+ Ok(ref n) => self
32510+ .conn
32511+ .connection
32512+ .execute_batch(&format!("RELEASE SAVEPOINT {n}"))?,
32513+ Err(ref i) => self
32514+ .conn
32515+ .connection
32516+ .execute_batch(&format!("RELEASE SAVEPOINT _{i}"))?,
32517+ };
32518+ self.committed = true;
32519+ }
32520+ Ok(())
32521+ }
32522+
32523+ /// Configure the savepoint to perform the specified action when it is
32524+ /// dropped.
32525+ #[inline]
32526+ pub fn set_drop_behavior(&mut self, drop_behavior: DropBehavior) {
32527+ self.drop_behavior = drop_behavior;
32528+ }
32529+
32530+ /// A convenience method which consumes and rolls back a savepoint.
32531+ #[inline]
32532+ pub fn rollback(mut self) -> Result<()> {
32533+ self.rollback_()
32534+ }
32535+
32536+ fn rollback_(&mut self) -> Result<()> {
32537+ if !self.committed {
32538+ match self.name {
32539+ Ok(ref n) => self
32540+ .conn
32541+ .connection
32542+ .execute_batch(&format!("ROLLBACK TO SAVEPOINT {n}"))?,
32543+ Err(ref i) => self
32544+ .conn
32545+ .connection
32546+ .execute_batch(&format!("ROLLBACK TO SAVEPOINT _{i}"))?,
32547+ };
32548+ }
32549+ Ok(())
32550+ }
32551+
32552+ /// Consumes the savepoint, committing or rolling back according to
32553+ /// the current setting (see `drop_behavior`).
32554+ ///
32555+ /// Functionally equivalent to the `Drop` implementation, but allows
32556+ /// callers to see any errors that occur.
32557+ #[inline]
32558+ pub fn finish(mut self) -> Result<()> {
32559+ self.finish_()
32560+ }
32561+
32562+ #[inline]
32563+ fn finish_(&mut self) -> Result<()> {
32564+ if self.conn.connection.is_autocommit() {
32565+ return Ok(());
32566+ }
32567+ match self.drop_behavior {
32568+ DropBehavior::Commit => self.commit_().or_else(|_| self.rollback_()),
32569+ DropBehavior::Rollback => self.rollback_(),
32570+ DropBehavior::Ignore => Ok(()),
32571+ DropBehavior::Panic => panic!("Savepoint dropped unexpectedly."),
32572+ }
32573+ }
32574+ }
32575+
32576+ impl std::ops::Deref for Savepoint<'_> {
32577+ type Target = Connection;
32578+
32579+ #[inline]
32580+ fn deref(&self) -> &Connection {
32581+ self.conn
32582+ }
32583+ }
32584+ }
32585+
32586+ #[cfg(test)]
32587+ mod tests {
32588+ use super::*;
32589+
32590+ #[test]
32591+ fn test_new_connection() {
32592+ use melib::smtp::{SmtpAuth, SmtpSecurity, SmtpServerConf};
32593+ use tempfile::TempDir;
32594+
32595+ use crate::SendMail;
32596+
32597+ let tmp_dir = TempDir::new().unwrap();
32598+ let db_path = tmp_dir.path().join("mpot.db");
32599+ let data_path = tmp_dir.path().to_path_buf();
32600+ let config = Configuration {
32601+ send_mail: SendMail::Smtp(SmtpServerConf {
32602+ hostname: "127.0.0.1".into(),
32603+ port: 25,
32604+ envelope_from: "foo-chat@example.com".into(),
32605+ auth: SmtpAuth::None,
32606+ security: SmtpSecurity::None,
32607+ extensions: Default::default(),
32608+ }),
32609+ db_path,
32610+ data_path,
32611+ administrators: vec![],
32612+ };
32613+ assert_eq!(
32614+ &Connection::open_db(config.clone()).unwrap_err().to_string(),
32615+ "Database doesn't exist"
32616+ );
32617+
32618+ _ = Connection::open_or_create_db(config).unwrap();
32619+ }
32620+
32621+ #[test]
32622+ fn test_transactions() {
32623+ use melib::smtp::{SmtpAuth, SmtpSecurity, SmtpServerConf};
32624+ use tempfile::TempDir;
32625+
32626+ use super::transaction::*;
32627+ use crate::SendMail;
32628+
32629+ let tmp_dir = TempDir::new().unwrap();
32630+ let db_path = tmp_dir.path().join("mpot.db");
32631+ let data_path = tmp_dir.path().to_path_buf();
32632+ let config = Configuration {
32633+ send_mail: SendMail::Smtp(SmtpServerConf {
32634+ hostname: "127.0.0.1".into(),
32635+ port: 25,
32636+ envelope_from: "foo-chat@example.com".into(),
32637+ auth: SmtpAuth::None,
32638+ security: SmtpSecurity::None,
32639+ extensions: Default::default(),
32640+ }),
32641+ db_path,
32642+ data_path,
32643+ administrators: vec![],
32644+ };
32645+ let list = MailingList {
32646+ pk: 0,
32647+ name: "".into(),
32648+ id: "".into(),
32649+ description: None,
32650+ topics: vec![],
32651+ address: "".into(),
32652+ archive_url: None,
32653+ };
32654+ let mut db = Connection::open_or_create_db(config).unwrap().trusted();
32655+
32656+ /* drop rollback */
32657+ let mut tx = db.transaction(Default::default()).unwrap();
32658+ tx.set_drop_behavior(DropBehavior::Rollback);
32659+ let _new = tx.create_list(list.clone()).unwrap();
32660+ drop(tx);
32661+ assert_eq!(&db.lists().unwrap(), &[]);
32662+
32663+ /* drop commit */
32664+ let mut tx = db.transaction(Default::default()).unwrap();
32665+ tx.set_drop_behavior(DropBehavior::Commit);
32666+ let new = tx.create_list(list.clone()).unwrap();
32667+ drop(tx);
32668+ assert_eq!(&db.lists().unwrap(), &[new.clone()]);
32669+
32670+ /* rollback with drop commit */
32671+ let mut tx = db.transaction(Default::default()).unwrap();
32672+ tx.set_drop_behavior(DropBehavior::Commit);
32673+ let _new2 = tx
32674+ .create_list(MailingList {
32675+ id: "1".into(),
32676+ address: "1".into(),
32677+ ..list.clone()
32678+ })
32679+ .unwrap();
32680+ tx.rollback().unwrap();
32681+ assert_eq!(&db.lists().unwrap(), &[new.clone()]);
32682+
32683+ /* tx and then savepoint */
32684+ let tx = db.transaction(Default::default()).unwrap();
32685+ let sv = tx.savepoint(None).unwrap();
32686+ let new2 = sv
32687+ .create_list(MailingList {
32688+ id: "2".into(),
32689+ address: "2".into(),
32690+ ..list.clone()
32691+ })
32692+ .unwrap();
32693+ sv.commit().unwrap();
32694+ tx.commit().unwrap();
32695+ assert_eq!(&db.lists().unwrap(), &[new.clone(), new2.clone()]);
32696+
32697+ /* tx and then rollback savepoint */
32698+ let tx = db.transaction(Default::default()).unwrap();
32699+ let sv = tx.savepoint(None).unwrap();
32700+ let _new3 = sv
32701+ .create_list(MailingList {
32702+ id: "3".into(),
32703+ address: "3".into(),
32704+ ..list.clone()
32705+ })
32706+ .unwrap();
32707+ sv.rollback().unwrap();
32708+ tx.commit().unwrap();
32709+ assert_eq!(&db.lists().unwrap(), &[new.clone(), new2.clone()]);
32710+
32711+ /* tx, commit savepoint and then rollback commit */
32712+ let tx = db.transaction(Default::default()).unwrap();
32713+ let sv = tx.savepoint(None).unwrap();
32714+ let _new3 = sv
32715+ .create_list(MailingList {
32716+ id: "3".into(),
32717+ address: "3".into(),
32718+ ..list.clone()
32719+ })
32720+ .unwrap();
32721+ sv.commit().unwrap();
32722+ tx.rollback().unwrap();
32723+ assert_eq!(&db.lists().unwrap(), &[new.clone(), new2.clone()]);
32724+
32725+ /* nested savepoints */
32726+ let tx = db.transaction(Default::default()).unwrap();
32727+ let sv = tx.savepoint(None).unwrap();
32728+ let sv1 = sv.savepoint(None).unwrap();
32729+ let new3 = sv1
32730+ .create_list(MailingList {
32731+ id: "3".into(),
32732+ address: "3".into(),
32733+ ..list
32734+ })
32735+ .unwrap();
32736+ sv1.commit().unwrap();
32737+ sv.commit().unwrap();
32738+ tx.commit().unwrap();
32739+ assert_eq!(&db.lists().unwrap(), &[new, new2, new3]);
32740+ }
32741+
32742+ #[test]
32743+ fn test_mbox_export() {
32744+ use tempfile::TempDir;
32745+
32746+ use crate::SendMail;
32747+
32748+ let tmp_dir = TempDir::new().unwrap();
32749+ let db_path = tmp_dir.path().join("mpot.db");
32750+ let data_path = tmp_dir.path().to_path_buf();
32751+ let config = Configuration {
32752+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
32753+ db_path,
32754+ data_path,
32755+ administrators: vec![],
32756+ };
32757+ let list = MailingList {
32758+ pk: 0,
32759+ name: "test".into(),
32760+ id: "test".into(),
32761+ description: None,
32762+ topics: vec![],
32763+ address: "test@example.com".into(),
32764+ archive_url: None,
32765+ };
32766+
32767+ let test_emails = vec![
32768+ r#"From: "User Name" <user@example.com>
32769+ To: "test" <test@example.com>
32770+ Subject: Hello World
32771+
32772+ Hello, this is a message.
32773+
32774+ Goodbye!
32775+
32776+ "#,
32777+ r#"From: "User Name" <user@example.com>
32778+ To: "test" <test@example.com>
32779+ Subject: Fuu Bar
32780+
32781+ Baz,
32782+
32783+ Qux!
32784+
32785+ "#,
32786+ ];
32787+ let db = Connection::open_or_create_db(config).unwrap().trusted();
32788+ db.create_list(list).unwrap();
32789+ for email in test_emails {
32790+ let envelope = melib::Envelope::from_bytes(email.as_bytes(), None).unwrap();
32791+ db.post(&envelope, email.as_bytes(), false).unwrap();
32792+ }
32793+ let mbox = String::from_utf8(db.export_mbox(1, None, false).unwrap()).unwrap();
32794+ assert!(
32795+ mbox.split('\n').fold(0, |accm, line| {
32796+ if line.starts_with("From MAILER-DAEMON") {
32797+ accm + 1
32798+ } else {
32799+ accm
32800+ }
32801+ }) == 2
32802+ )
32803+ }
32804+ }
32805 diff --git a/mailpot/src/doctests/db_setup.rs.inc b/mailpot/src/doctests/db_setup.rs.inc
32806new file mode 100644
32807index 0000000..46b82ca
32808--- /dev/null
32809+++ b/mailpot/src/doctests/db_setup.rs.inc
32810 @@ -0,0 +1,53 @@
32811+ # use mailpot::{*, models::*};
32812+ # use melib::smtp::{SmtpServerConf, SmtpAuth, SmtpSecurity};
32813+ #
32814+ # use tempfile::TempDir;
32815+ #
32816+ # let tmp_dir = TempDir::new()?;
32817+ # let db_path = tmp_dir.path().join("mpot.db");
32818+ # let data_path = tmp_dir.path().to_path_buf();
32819+ # let config = Configuration {
32820+ # send_mail: mailpot::SendMail::Smtp(
32821+ # SmtpServerConf {
32822+ # hostname: "127.0.0.1".into(),
32823+ # port: 25,
32824+ # envelope_from: "foo-chat@example.com".into(),
32825+ # auth: SmtpAuth::None,
32826+ # security: SmtpSecurity::None,
32827+ # extensions: Default::default(),
32828+ # }
32829+ # ),
32830+ # db_path,
32831+ # data_path,
32832+ # administrators: vec![],
32833+ # };
32834+ # let db = Connection::open_or_create_db(config)?.trusted();
32835+ # let list = db
32836+ # .create_list(MailingList {
32837+ # pk: 5,
32838+ # name: "foobar chat".into(),
32839+ # id: "foo-chat".into(),
32840+ # address: "foo-chat@example.com".into(),
32841+ # description: Some("Hello world, from foo-chat list".into()),
32842+ # topics: vec![],
32843+ # archive_url: Some("https://lists.example.com".into()),
32844+ # })
32845+ # .unwrap();
32846+ # let sub_policy = SubscriptionPolicy {
32847+ # pk: 1,
32848+ # list: 5,
32849+ # send_confirmation: true,
32850+ # open: false,
32851+ # manual: false,
32852+ # request: true,
32853+ # custom: false,
32854+ # };
32855+ # let post_policy = PostPolicy {
32856+ # pk: 1,
32857+ # list: 5,
32858+ # announce_only: false,
32859+ # subscription_only: false,
32860+ # approval_needed: false,
32861+ # open: true,
32862+ # custom: false,
32863+ # };
32864 diff --git a/mailpot/src/errors.rs b/mailpot/src/errors.rs
32865new file mode 100644
32866index 0000000..da07e70
32867--- /dev/null
32868+++ b/mailpot/src/errors.rs
32869 @@ -0,0 +1,232 @@
32870+ /*
32871+ * This file is part of mailpot
32872+ *
32873+ * Copyright 2020 - Manos Pitsidianakis
32874+ *
32875+ * This program is free software: you can redistribute it and/or modify
32876+ * it under the terms of the GNU Affero General Public License as
32877+ * published by the Free Software Foundation, either version 3 of the
32878+ * License, or (at your option) any later version.
32879+ *
32880+ * This program is distributed in the hope that it will be useful,
32881+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
32882+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
32883+ * GNU Affero General Public License for more details.
32884+ *
32885+ * You should have received a copy of the GNU Affero General Public License
32886+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
32887+ */
32888+
32889+ //! Errors of this library.
32890+
32891+ use std::sync::Arc;
32892+
32893+ use thiserror::Error;
32894+
32895+ /// Mailpot library error.
32896+ #[derive(Error, Debug)]
32897+ pub struct Error {
32898+ kind: ErrorKind,
32899+ source: Option<Arc<Self>>,
32900+ }
32901+
32902+ /// Mailpot library error.
32903+ #[derive(Error, Debug)]
32904+ pub enum ErrorKind {
32905+ /// Post rejected.
32906+ #[error("Your post has been rejected: {0}")]
32907+ PostRejected(String),
32908+ /// An entry was not found in the database.
32909+ #[error("This {0} is not present in the database.")]
32910+ NotFound(&'static str),
32911+ /// A request was invalid.
32912+ #[error("Your list request has been found invalid: {0}.")]
32913+ InvalidRequest(String),
32914+ /// An error happened and it was handled internally.
32915+ #[error("An error happened and it was handled internally: {0}.")]
32916+ Information(String),
32917+ /// An error that shouldn't happen and should be reported.
32918+ #[error("An error that shouldn't happen and should be reported: {0}.")]
32919+ Bug(String),
32920+
32921+ /// Error returned from an external user initiated operation such as
32922+ /// deserialization or I/O.
32923+ #[error("Error: {0}")]
32924+ External(#[from] anyhow::Error),
32925+ /// Generic
32926+ #[error("{0}")]
32927+ Generic(anyhow::Error),
32928+ /// Error returned from sqlite3.
32929+ #[error("Error returned from sqlite3: {0}.")]
32930+ Sql(
32931+ #[from]
32932+ #[source]
32933+ rusqlite::Error,
32934+ ),
32935+ /// Error returned from sqlite3.
32936+ #[error("Error returned from sqlite3: {0}")]
32937+ SqlLib(
32938+ #[from]
32939+ #[source]
32940+ rusqlite::ffi::Error,
32941+ ),
32942+ /// Error returned from internal I/O operations.
32943+ #[error("Error returned from internal I/O operation: {0}")]
32944+ Io(#[from] ::std::io::Error),
32945+ /// Error returned from e-mail protocol operations from `melib` crate.
32946+ #[error("Error returned from e-mail protocol operations from `melib` crate: {0}")]
32947+ Melib(#[from] melib::error::Error),
32948+ /// Error from deserializing JSON values.
32949+ #[error("Error from deserializing JSON values: {0}")]
32950+ SerdeJson(#[from] serde_json::Error),
32951+ /// Error returned from minijinja template engine.
32952+ #[error("Error returned from minijinja template engine: {0}")]
32953+ Template(#[from] minijinja::Error),
32954+ }
32955+
32956+ impl std::fmt::Display for Error {
32957+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
32958+ write!(fmt, "{}", self.kind)
32959+ }
32960+ }
32961+
32962+ impl From<ErrorKind> for Error {
32963+ fn from(kind: ErrorKind) -> Self {
32964+ Self { kind, source: None }
32965+ }
32966+ }
32967+
32968+ macro_rules! impl_from {
32969+ ($ty:ty) => {
32970+ impl From<$ty> for Error {
32971+ fn from(err: $ty) -> Self {
32972+ Self {
32973+ kind: err.into(),
32974+ source: None,
32975+ }
32976+ }
32977+ }
32978+ };
32979+ }
32980+
32981+ impl_from! { anyhow::Error }
32982+ impl_from! { rusqlite::Error }
32983+ impl_from! { rusqlite::ffi::Error }
32984+ impl_from! { ::std::io::Error }
32985+ impl_from! { melib::error::Error }
32986+ impl_from! { serde_json::Error }
32987+ impl_from! { minijinja::Error }
32988+
32989+ impl Error {
32990+ /// Helper function to create a new generic error message.
32991+ pub fn new_external<S: Into<String>>(msg: S) -> Self {
32992+ let msg = msg.into();
32993+ ErrorKind::External(anyhow::Error::msg(msg)).into()
32994+ }
32995+
32996+ /// Chain an error by introducing a new head of the error chain.
32997+ pub fn chain_err<E>(self, lambda: impl FnOnce() -> E) -> Self
32998+ where
32999+ E: Into<Self>,
33000+ {
33001+ let new_head: Self = lambda().into();
33002+ Self {
33003+ source: Some(Arc::new(self)),
33004+ ..new_head
33005+ }
33006+ }
33007+
33008+ /// Insert a source error into this Error.
33009+ pub fn with_source<E>(self, source: E) -> Self
33010+ where
33011+ E: Into<Self>,
33012+ {
33013+ Self {
33014+ source: Some(Arc::new(source.into())),
33015+ ..self
33016+ }
33017+ }
33018+
33019+ /// Getter for the kind field.
33020+ pub fn kind(&self) -> &ErrorKind {
33021+ &self.kind
33022+ }
33023+
33024+ /// Display error chain to user.
33025+ pub fn display_chain(&'_ self) -> impl std::fmt::Display + '_ {
33026+ ErrorChainDisplay {
33027+ current: self,
33028+ counter: 1,
33029+ }
33030+ }
33031+ }
33032+
33033+ impl From<String> for Error {
33034+ fn from(s: String) -> Self {
33035+ ErrorKind::Generic(anyhow::Error::msg(s)).into()
33036+ }
33037+ }
33038+ impl From<&str> for Error {
33039+ fn from(s: &str) -> Self {
33040+ ErrorKind::Generic(anyhow::Error::msg(s.to_string())).into()
33041+ }
33042+ }
33043+
33044+ /// Type alias for Mailpot library Results.
33045+ pub type Result<T> = std::result::Result<T, Error>;
33046+
33047+ struct ErrorChainDisplay<'e> {
33048+ current: &'e Error,
33049+ counter: usize,
33050+ }
33051+
33052+ impl std::fmt::Display for ErrorChainDisplay<'_> {
33053+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
33054+ if let Some(ref source) = self.current.source {
33055+ writeln!(fmt, "[{}] {} Caused by:", self.counter, self.current.kind)?;
33056+ Self {
33057+ current: source,
33058+ counter: self.counter + 1,
33059+ }
33060+ .fmt(fmt)
33061+ } else {
33062+ writeln!(fmt, "[{}] {}", self.counter, self.current.kind)?;
33063+ Ok(())
33064+ }
33065+ }
33066+ }
33067+
33068+ /// adfsa
33069+ pub trait Context<T> {
33070+ /// Wrap the error value with additional context.
33071+ fn context<C>(self, context: C) -> Result<T>
33072+ where
33073+ C: Into<Error>;
33074+
33075+ /// Wrap the error value with additional context that is evaluated lazily
33076+ /// only once an error does occur.
33077+ fn with_context<C, F>(self, f: F) -> Result<T>
33078+ where
33079+ C: Into<Error>,
33080+ F: FnOnce() -> C;
33081+ }
33082+
33083+ impl<T, E> Context<T> for std::result::Result<T, E>
33084+ where
33085+ Error: From<E>,
33086+ {
33087+ fn context<C>(self, context: C) -> Result<T>
33088+ where
33089+ C: Into<Error>,
33090+ {
33091+ self.map_err(|err| Error::from(err).chain_err(|| context.into()))
33092+ }
33093+
33094+ fn with_context<C, F>(self, f: F) -> Result<T>
33095+ where
33096+ C: Into<Error>,
33097+ F: FnOnce() -> C,
33098+ {
33099+ self.map_err(|err| Error::from(err).chain_err(|| f().into()))
33100+ }
33101+ }
33102 diff --git a/mailpot/src/lib.rs b/mailpot/src/lib.rs
33103new file mode 100644
33104index 0000000..e56a80a
33105--- /dev/null
33106+++ b/mailpot/src/lib.rs
33107 @@ -0,0 +1,259 @@
33108+ /*
33109+ * This file is part of mailpot
33110+ *
33111+ * Copyright 2020 - Manos Pitsidianakis
33112+ *
33113+ * This program is free software: you can redistribute it and/or modify
33114+ * it under the terms of the GNU Affero General Public License as
33115+ * published by the Free Software Foundation, either version 3 of the
33116+ * License, or (at your option) any later version.
33117+ *
33118+ * This program is distributed in the hope that it will be useful,
33119+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
33120+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
33121+ * GNU Affero General Public License for more details.
33122+ *
33123+ * You should have received a copy of the GNU Affero General Public License
33124+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
33125+ */
33126+
33127+ #![deny(
33128+ missing_docs,
33129+ rustdoc::broken_intra_doc_links,
33130+ /* groups */
33131+ clippy::correctness,
33132+ clippy::suspicious,
33133+ clippy::complexity,
33134+ clippy::perf,
33135+ clippy::style,
33136+ clippy::cargo,
33137+ clippy::nursery,
33138+ /* restriction */
33139+ clippy::dbg_macro,
33140+ clippy::rc_buffer,
33141+ clippy::as_underscore,
33142+ clippy::assertions_on_result_states,
33143+ /* pedantic */
33144+ clippy::cast_lossless,
33145+ clippy::cast_possible_wrap,
33146+ clippy::ptr_as_ptr,
33147+ clippy::bool_to_int_with_if,
33148+ clippy::borrow_as_ptr,
33149+ clippy::case_sensitive_file_extension_comparisons,
33150+ clippy::cast_lossless,
33151+ clippy::cast_ptr_alignment,
33152+ clippy::naive_bytecount
33153+ )]
33154+ #![allow(clippy::multiple_crate_versions, clippy::missing_const_for_fn)]
33155+
33156+ //! Mailing list manager library.
33157+ //!
33158+ //! Data is stored in a `sqlite3` database.
33159+ //! You can inspect the schema in [`SCHEMA`](crate::Connection::SCHEMA).
33160+ //!
33161+ //! # Usage
33162+ //!
33163+ //! `mailpot` can be used with the CLI tool in [`mailpot-cli`](mailpot-cli),
33164+ //! and/or in the web interface of the [`mailpot-web`](mailpot-web) crate.
33165+ //!
33166+ //! You can also directly use this crate as a library.
33167+ //!
33168+ //! # Example
33169+ //!
33170+ //! ```
33171+ //! use mailpot::{models::*, Configuration, Connection, SendMail};
33172+ //! # use tempfile::TempDir;
33173+ //!
33174+ //! # let tmp_dir = TempDir::new().unwrap();
33175+ //! # let db_path = tmp_dir.path().join("mpot.db");
33176+ //! # let config = Configuration {
33177+ //! # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
33178+ //! # db_path: db_path.clone(),
33179+ //! # data_path: tmp_dir.path().to_path_buf(),
33180+ //! # administrators: vec![],
33181+ //! # };
33182+ //! #
33183+ //! # fn do_test(config: Configuration) -> mailpot::Result<()> {
33184+ //! let db = Connection::open_or_create_db(config)?.trusted();
33185+ //!
33186+ //! // Create a new mailing list
33187+ //! let list_pk = db
33188+ //! .create_list(MailingList {
33189+ //! pk: 0,
33190+ //! name: "foobar chat".into(),
33191+ //! id: "foo-chat".into(),
33192+ //! address: "foo-chat@example.com".into(),
33193+ //! topics: vec![],
33194+ //! description: None,
33195+ //! archive_url: None,
33196+ //! })?
33197+ //! .pk;
33198+ //!
33199+ //! db.set_list_post_policy(PostPolicy {
33200+ //! pk: 0,
33201+ //! list: list_pk,
33202+ //! announce_only: false,
33203+ //! subscription_only: true,
33204+ //! approval_needed: false,
33205+ //! open: false,
33206+ //! custom: false,
33207+ //! })?;
33208+ //!
33209+ //! // Drop privileges; we can only process new e-mail and modify subscriptions from now on.
33210+ //! let mut db = db.untrusted();
33211+ //!
33212+ //! assert_eq!(db.list_subscriptions(list_pk)?.len(), 0);
33213+ //! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
33214+ //!
33215+ //! // Process a subscription request e-mail
33216+ //! let subscribe_bytes = b"From: Name <user@example.com>
33217+ //! To: <foo-chat+subscribe@example.com>
33218+ //! Subject: subscribe
33219+ //! Date: Thu, 29 Oct 2020 13:58:16 +0000
33220+ //! Message-ID: <1@example.com>
33221+ //!
33222+ //! ";
33223+ //! let envelope = melib::Envelope::from_bytes(subscribe_bytes, None)?;
33224+ //! db.post(&envelope, subscribe_bytes, /* dry_run */ false)?;
33225+ //!
33226+ //! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
33227+ //! assert_eq!(db.list_posts(list_pk, None)?.len(), 0);
33228+ //!
33229+ //! // Process a post
33230+ //! let post_bytes = b"From: Name <user@example.com>
33231+ //! To: <foo-chat@example.com>
33232+ //! Subject: my first post
33233+ //! Date: Thu, 29 Oct 2020 14:01:09 +0000
33234+ //! Message-ID: <2@example.com>
33235+ //!
33236+ //! Hello
33237+ //! ";
33238+ //! let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
33239+ //! db.post(&envelope, post_bytes, /* dry_run */ false)?;
33240+ //!
33241+ //! assert_eq!(db.list_subscriptions(list_pk)?.len(), 1);
33242+ //! assert_eq!(db.list_posts(list_pk, None)?.len(), 1);
33243+ //! # Ok(())
33244+ //! # }
33245+ //! # do_test(config);
33246+ //! ```
33247+
33248+ /* Annotations:
33249+ *
33250+ * Global tags (in tagref format <https://github.com/stepchowfun/tagref>) for source code
33251+ * annotation:
33252+ *
33253+ * - [tag:needs_unit_test]
33254+ * - [tag:needs_user_doc]
33255+ * - [tag:needs_dev_doc]
33256+ * - [tag:FIXME]
33257+ * - [tag:TODO]
33258+ * - [tag:VERIFY] Verify whether this is the correct way to do something
33259+ */
33260+
33261+ /// Error library
33262+ pub extern crate anyhow;
33263+ /// Date library
33264+ pub extern crate chrono;
33265+ /// Sql library
33266+ pub extern crate rusqlite;
33267+
33268+ /// Alias for [`chrono::DateTime<chrono::Utc>`].
33269+ pub type DateTime = chrono::DateTime<chrono::Utc>;
33270+
33271+ /// Serde
33272+ #[macro_use]
33273+ pub extern crate serde;
33274+ /// Log
33275+ pub extern crate log;
33276+ /// melib
33277+ pub extern crate melib;
33278+ /// serde_json
33279+ pub extern crate serde_json;
33280+
33281+ mod config;
33282+ mod connection;
33283+ mod errors;
33284+ pub mod mail;
33285+ pub mod message_filters;
33286+ pub mod models;
33287+ pub mod policies;
33288+ #[cfg(not(target_os = "windows"))]
33289+ pub mod postfix;
33290+ pub mod posts;
33291+ pub mod queue;
33292+ pub mod submission;
33293+ pub mod subscriptions;
33294+ mod templates;
33295+
33296+ pub use config::{Configuration, SendMail};
33297+ pub use connection::{transaction, *};
33298+ pub use errors::*;
33299+ use models::*;
33300+ pub use templates::*;
33301+
33302+ /// A `mailto:` value.
33303+ #[derive(Debug, Clone, Deserialize, Serialize)]
33304+ pub struct MailtoAddress {
33305+ /// E-mail address.
33306+ pub address: String,
33307+ /// Optional subject value.
33308+ pub subject: Option<String>,
33309+ }
33310+
33311+ #[doc = include_str!("../../README.md")]
33312+ #[cfg(doctest)]
33313+ pub struct ReadmeDoctests;
33314+
33315+ /// Trait for stripping carets ('<','>') from Message IDs.
33316+ pub trait StripCarets {
33317+ /// If `self` is surrounded by carets, strip them.
33318+ fn strip_carets(&self) -> &str;
33319+ }
33320+
33321+ impl StripCarets for &str {
33322+ fn strip_carets(&self) -> &str {
33323+ let mut self_ref = self.trim();
33324+ if self_ref.starts_with('<') && self_ref.ends_with('>') {
33325+ self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
33326+ }
33327+ self_ref
33328+ }
33329+ }
33330+
33331+ /// Trait for stripping carets ('<','>') from Message IDs inplace.
33332+ pub trait StripCaretsInplace {
33333+ /// If `self` is surrounded by carets, strip them.
33334+ fn strip_carets_inplace(self) -> Self;
33335+ }
33336+
33337+ impl StripCaretsInplace for &str {
33338+ fn strip_carets_inplace(self) -> Self {
33339+ let mut self_ref = self.trim();
33340+ if self_ref.starts_with('<') && self_ref.ends_with('>') {
33341+ self_ref = &self_ref[1..self_ref.len().saturating_sub(1)];
33342+ }
33343+ self_ref
33344+ }
33345+ }
33346+
33347+ impl StripCaretsInplace for String {
33348+ fn strip_carets_inplace(mut self) -> Self {
33349+ if self.starts_with('<') && self.ends_with('>') {
33350+ self.drain(0..1);
33351+ let len = self.len();
33352+ self.drain(len.saturating_sub(1)..len);
33353+ }
33354+ self
33355+ }
33356+ }
33357+
33358+ use percent_encoding::CONTROLS;
33359+ pub use percent_encoding::{utf8_percent_encode, AsciiSet};
33360+
33361+ // from https://github.com/servo/rust-url/blob/master/url/src/parser.rs
33362+ const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
33363+ const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
33364+
33365+ /// Set for percent encoding URL components.
33366+ pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');
33367 diff --git a/mailpot/src/mail.rs b/mailpot/src/mail.rs
33368new file mode 100644
33369index 0000000..b33e715
33370--- /dev/null
33371+++ b/mailpot/src/mail.rs
33372 @@ -0,0 +1,181 @@
33373+ /*
33374+ * This file is part of mailpot
33375+ *
33376+ * Copyright 2020 - Manos Pitsidianakis
33377+ *
33378+ * This program is free software: you can redistribute it and/or modify
33379+ * it under the terms of the GNU Affero General Public License as
33380+ * published by the Free Software Foundation, either version 3 of the
33381+ * License, or (at your option) any later version.
33382+ *
33383+ * This program is distributed in the hope that it will be useful,
33384+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
33385+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
33386+ * GNU Affero General Public License for more details.
33387+ *
33388+ * You should have received a copy of the GNU Affero General Public License
33389+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
33390+ */
33391+
33392+ //! Types for processing new posts:
33393+ //! [`PostFilter`](crate::message_filters::PostFilter), [`ListContext`],
33394+ //! [`MailJob`] and [`PostAction`].
33395+
33396+ use std::collections::HashMap;
33397+
33398+ use log::trace;
33399+ use melib::{Address, MessageID};
33400+
33401+ use crate::{
33402+ models::{ListOwner, ListSubscription, MailingList, PostPolicy, SubscriptionPolicy},
33403+ DbVal,
33404+ };
33405+ /// Post action returned from a list's
33406+ /// [`PostFilter`](crate::message_filters::PostFilter) stack.
33407+ #[derive(Debug)]
33408+ pub enum PostAction {
33409+ /// Add to `hold` queue.
33410+ Hold,
33411+ /// Accept to mailing list.
33412+ Accept,
33413+ /// Reject and send rejection response to submitter.
33414+ Reject {
33415+ /// Human readable reason for rejection.
33416+ reason: String,
33417+ },
33418+ /// Add to `deferred` queue.
33419+ Defer {
33420+ /// Human readable reason for deferring.
33421+ reason: String,
33422+ },
33423+ }
33424+
33425+ /// List context passed to a list's
33426+ /// [`PostFilter`](crate::message_filters::PostFilter) stack.
33427+ #[derive(Debug)]
33428+ pub struct ListContext<'list> {
33429+ /// Which mailing list a post was addressed to.
33430+ pub list: &'list MailingList,
33431+ /// The mailing list owners.
33432+ pub list_owners: &'list [DbVal<ListOwner>],
33433+ /// The mailing list subscriptions.
33434+ pub subscriptions: &'list [DbVal<ListSubscription>],
33435+ /// The mailing list post policy.
33436+ pub post_policy: Option<DbVal<PostPolicy>>,
33437+ /// The mailing list subscription policy.
33438+ pub subscription_policy: Option<DbVal<SubscriptionPolicy>>,
33439+ /// The scheduled jobs added by each filter in a list's
33440+ /// [`PostFilter`](crate::message_filters::PostFilter) stack.
33441+ pub scheduled_jobs: Vec<MailJob>,
33442+ /// Saved settings for message filters, which process a
33443+ /// received e-mail before taking a final decision/action.
33444+ pub filter_settings: HashMap<String, DbVal<serde_json::Value>>,
33445+ }
33446+
33447+ /// Post to be considered by the list's
33448+ /// [`PostFilter`](crate::message_filters::PostFilter) stack.
33449+ pub struct PostEntry {
33450+ /// `From` address of post.
33451+ pub from: Address,
33452+ /// Raw bytes of post.
33453+ pub bytes: Vec<u8>,
33454+ /// `To` addresses of post.
33455+ pub to: Vec<Address>,
33456+ /// Final action set by each filter in a list's
33457+ /// [`PostFilter`](crate::message_filters::PostFilter) stack.
33458+ pub action: PostAction,
33459+ /// Post's Message-ID
33460+ pub message_id: MessageID,
33461+ }
33462+
33463+ impl core::fmt::Debug for PostEntry {
33464+ fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::fmt::Result {
33465+ fmt.debug_struct(stringify!(PostEntry))
33466+ .field("from", &self.from)
33467+ .field("message_id", &self.message_id)
33468+ .field("bytes", &format_args!("{} bytes", self.bytes.len()))
33469+ .field("to", &self.to.as_slice())
33470+ .field("action", &self.action)
33471+ .finish()
33472+ }
33473+ }
33474+
33475+ /// Scheduled jobs added to a [`ListContext`] by a list's
33476+ /// [`PostFilter`](crate::message_filters::PostFilter) stack.
33477+ #[derive(Debug)]
33478+ pub enum MailJob {
33479+ /// Send post to recipients.
33480+ Send {
33481+ /// The post recipients addresses.
33482+ recipients: Vec<Address>,
33483+ },
33484+ /// Send error to submitter.
33485+ Error {
33486+ /// Human readable description of the error.
33487+ description: String,
33488+ },
33489+ /// Store post in digest for recipients.
33490+ StoreDigest {
33491+ /// The digest recipients addresses.
33492+ recipients: Vec<Address>,
33493+ },
33494+ /// Reply with subscription confirmation to submitter.
33495+ ConfirmSubscription {
33496+ /// The submitter address.
33497+ recipient: Address,
33498+ },
33499+ /// Reply with unsubscription confirmation to submitter.
33500+ ConfirmUnsubscription {
33501+ /// The submitter address.
33502+ recipient: Address,
33503+ },
33504+ }
33505+
33506+ /// Type of mailing list request.
33507+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
33508+ pub enum ListRequest {
33509+ /// Get help about a mailing list and its available interfaces.
33510+ Help,
33511+ /// Request subscription.
33512+ Subscribe,
33513+ /// Request removal of subscription.
33514+ Unsubscribe,
33515+ /// Request reception of list posts from a month-year range, inclusive.
33516+ RetrieveArchive(String, String),
33517+ /// Request reception of specific mailing list posts from `Message-ID`
33518+ /// values.
33519+ RetrieveMessages(Vec<String>),
33520+ /// Request change in subscription settings.
33521+ /// See [`ListSubscription`].
33522+ ChangeSetting(String, bool),
33523+ /// Other type of request.
33524+ Other(String),
33525+ }
33526+
33527+ impl std::fmt::Display for ListRequest {
33528+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
33529+ write!(fmt, "{:?}", self)
33530+ }
33531+ }
33532+
33533+ impl<S: AsRef<str>> TryFrom<(S, &melib::Envelope)> for ListRequest {
33534+ type Error = crate::Error;
33535+
33536+ fn try_from((val, env): (S, &melib::Envelope)) -> std::result::Result<Self, Self::Error> {
33537+ let val = val.as_ref();
33538+ Ok(match val {
33539+ "subscribe" => Self::Subscribe,
33540+ "request" if env.subject().trim() == "subscribe" => Self::Subscribe,
33541+ "unsubscribe" => Self::Unsubscribe,
33542+ "request" if env.subject().trim() == "unsubscribe" => Self::Unsubscribe,
33543+ "help" => Self::Help,
33544+ "request" if env.subject().trim() == "help" => Self::Help,
33545+ "request" => Self::Other(env.subject().trim().to_string()),
33546+ _ => {
33547+ // [ref:TODO] add ChangeSetting parsing
33548+ trace!("unknown action = {} for addresses {:?}", val, env.from(),);
33549+ Self::Other(val.trim().to_string())
33550+ }
33551+ })
33552+ }
33553+ }
33554 diff --git a/mailpot/src/message_filters.rs b/mailpot/src/message_filters.rs
33555new file mode 100644
33556index 0000000..553a471
33557--- /dev/null
33558+++ b/mailpot/src/message_filters.rs
33559 @@ -0,0 +1,406 @@
33560+ /*
33561+ * This file is part of mailpot
33562+ *
33563+ * Copyright 2020 - Manos Pitsidianakis
33564+ *
33565+ * This program is free software: you can redistribute it and/or modify
33566+ * it under the terms of the GNU Affero General Public License as
33567+ * published by the Free Software Foundation, either version 3 of the
33568+ * License, or (at your option) any later version.
33569+ *
33570+ * This program is distributed in the hope that it will be useful,
33571+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
33572+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
33573+ * GNU Affero General Public License for more details.
33574+ *
33575+ * You should have received a copy of the GNU Affero General Public License
33576+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
33577+ */
33578+
33579+ #![allow(clippy::result_unit_err)]
33580+
33581+ //! Filters to pass each mailing list post through. Filters are functions that
33582+ //! implement the [`PostFilter`] trait that can:
33583+ //!
33584+ //! - transform post content.
33585+ //! - modify the final [`PostAction`] to take.
33586+ //! - modify the final scheduled jobs to perform. (See [`MailJob`]).
33587+ //!
33588+ //! Filters are executed in sequence like this:
33589+ //!
33590+ //! ```ignore
33591+ //! let result = filters
33592+ //! .into_iter()
33593+ //! .fold(Ok((&mut post, &mut list_ctx)), |p, f| {
33594+ //! p.and_then(|(p, c)| f.feed(p, c))
33595+ //! });
33596+ //! ```
33597+ //!
33598+ //! so the processing stops at the first returned error.
33599+
33600+ mod settings;
33601+ use log::trace;
33602+ use melib::{Address, HeaderName};
33603+ use percent_encoding::utf8_percent_encode;
33604+
33605+ use crate::{
33606+ mail::{ListContext, MailJob, PostAction, PostEntry},
33607+ models::{DbVal, MailingList},
33608+ Connection, StripCarets, PATH_SEGMENT,
33609+ };
33610+
33611+ impl Connection {
33612+ /// Return the post filters of a mailing list.
33613+ pub fn list_filters(&self, _list: &DbVal<MailingList>) -> Vec<Box<dyn PostFilter>> {
33614+ vec![
33615+ Box::new(PostRightsCheck),
33616+ Box::new(MimeReject),
33617+ Box::new(FixCRLF),
33618+ Box::new(AddListHeaders),
33619+ Box::new(ArchivedAtLink),
33620+ Box::new(AddSubjectTagPrefix),
33621+ Box::new(FinalizeRecipients),
33622+ ]
33623+ }
33624+ }
33625+
33626+ /// Filter that modifies and/or verifies a post candidate. On rejection, return
33627+ /// a string describing the error and optionally set `post.action` to `Reject`
33628+ /// or `Defer`
33629+ pub trait PostFilter {
33630+ /// Feed post into the filter. Perform modifications to `post` and / or
33631+ /// `ctx`, and return them with `Result::Ok` unless you want to the
33632+ /// processing to stop and return an `Result::Err`.
33633+ fn feed<'p, 'list>(
33634+ self: Box<Self>,
33635+ post: &'p mut PostEntry,
33636+ ctx: &'p mut ListContext<'list>,
33637+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()>;
33638+ }
33639+
33640+ /// Check that submitter can post to list, for now it accepts everything.
33641+ pub struct PostRightsCheck;
33642+ impl PostFilter for PostRightsCheck {
33643+ fn feed<'p, 'list>(
33644+ self: Box<Self>,
33645+ post: &'p mut PostEntry,
33646+ ctx: &'p mut ListContext<'list>,
33647+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
33648+ trace!("Running PostRightsCheck filter");
33649+ if let Some(ref policy) = ctx.post_policy {
33650+ if policy.announce_only {
33651+ trace!("post policy is announce_only");
33652+ let owner_addresses = ctx
33653+ .list_owners
33654+ .iter()
33655+ .map(|lo| lo.address())
33656+ .collect::<Vec<Address>>();
33657+ trace!("Owner addresses are: {:#?}", &owner_addresses);
33658+ trace!("Envelope from is: {:?}", &post.from);
33659+ if !owner_addresses.iter().any(|addr| *addr == post.from) {
33660+ trace!("Envelope From does not include any owner");
33661+ post.action = PostAction::Reject {
33662+ reason: "You are not allowed to post on this list.".to_string(),
33663+ };
33664+ return Err(());
33665+ }
33666+ } else if policy.subscription_only {
33667+ trace!("post policy is subscription_only");
33668+ let email_from = post.from.get_email();
33669+ trace!("post from is {:?}", &email_from);
33670+ trace!("post subscriptions are {:#?}", &ctx.subscriptions);
33671+ if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
33672+ trace!("Envelope from is not subscribed to this list");
33673+ post.action = PostAction::Reject {
33674+ reason: "Only subscriptions can post to this list.".to_string(),
33675+ };
33676+ return Err(());
33677+ }
33678+ } else if policy.approval_needed {
33679+ trace!("post policy says approval_needed");
33680+ let email_from = post.from.get_email();
33681+ trace!("post from is {:?}", &email_from);
33682+ trace!("post subscriptions are {:#?}", &ctx.subscriptions);
33683+ if !ctx.subscriptions.iter().any(|lm| lm.address == email_from) {
33684+ trace!("Envelope from is not subscribed to this list");
33685+ post.action = PostAction::Defer {
33686+ reason: "Your posting has been deferred. Approval from the list's \
33687+ moderators is required before it is submitted."
33688+ .to_string(),
33689+ };
33690+ return Err(());
33691+ }
33692+ }
33693+ }
33694+ Ok((post, ctx))
33695+ }
33696+ }
33697+
33698+ /// Ensure message contains only `\r\n` line terminators, required by SMTP.
33699+ pub struct FixCRLF;
33700+ impl PostFilter for FixCRLF {
33701+ fn feed<'p, 'list>(
33702+ self: Box<Self>,
33703+ post: &'p mut PostEntry,
33704+ ctx: &'p mut ListContext<'list>,
33705+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
33706+ trace!("Running FixCRLF filter");
33707+ use std::io::prelude::*;
33708+ let mut new_vec = Vec::with_capacity(post.bytes.len());
33709+ for line in post.bytes.lines() {
33710+ new_vec.extend_from_slice(line.unwrap().as_bytes());
33711+ new_vec.extend_from_slice(b"\r\n");
33712+ }
33713+ post.bytes = new_vec;
33714+ Ok((post, ctx))
33715+ }
33716+ }
33717+
33718+ /// Add `List-*` headers
33719+ pub struct AddListHeaders;
33720+ impl PostFilter for AddListHeaders {
33721+ fn feed<'p, 'list>(
33722+ self: Box<Self>,
33723+ post: &'p mut PostEntry,
33724+ ctx: &'p mut ListContext<'list>,
33725+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
33726+ trace!("Running AddListHeaders filter");
33727+ let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
33728+ let sender = format!("<{}>", ctx.list.address);
33729+ headers.push((HeaderName::SENDER, sender.as_bytes()));
33730+
33731+ let list_id = Some(ctx.list.id_header());
33732+ let list_help = ctx.list.help_header();
33733+ let list_post = ctx.list.post_header(ctx.post_policy.as_deref());
33734+ let list_unsubscribe = ctx
33735+ .list
33736+ .unsubscribe_header(ctx.subscription_policy.as_deref());
33737+ let list_subscribe = ctx
33738+ .list
33739+ .subscribe_header(ctx.subscription_policy.as_deref());
33740+ let list_archive = ctx.list.archive_header();
33741+
33742+ for (hdr, val) in [
33743+ (HeaderName::LIST_ID, &list_id),
33744+ (HeaderName::LIST_HELP, &list_help),
33745+ (HeaderName::LIST_POST, &list_post),
33746+ (HeaderName::LIST_UNSUBSCRIBE, &list_unsubscribe),
33747+ (HeaderName::LIST_SUBSCRIBE, &list_subscribe),
33748+ (HeaderName::LIST_ARCHIVE, &list_archive),
33749+ ] {
33750+ if let Some(val) = val {
33751+ headers.push((hdr, val.as_bytes()));
33752+ }
33753+ }
33754+
33755+ let mut new_vec = Vec::with_capacity(
33756+ headers
33757+ .iter()
33758+ .map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
33759+ .sum::<usize>()
33760+ + "\r\n\r\n".len()
33761+ + body.len(),
33762+ );
33763+ for (h, v) in headers {
33764+ new_vec.extend_from_slice(h.as_str().as_bytes());
33765+ new_vec.extend_from_slice(b": ");
33766+ new_vec.extend_from_slice(v);
33767+ new_vec.extend_from_slice(b"\r\n");
33768+ }
33769+ new_vec.extend_from_slice(b"\r\n\r\n");
33770+ new_vec.extend_from_slice(body);
33771+
33772+ post.bytes = new_vec;
33773+ Ok((post, ctx))
33774+ }
33775+ }
33776+
33777+ /// Add List ID prefix in Subject header (e.g. `[list-id] ...`)
33778+ pub struct AddSubjectTagPrefix;
33779+ impl PostFilter for AddSubjectTagPrefix {
33780+ fn feed<'p, 'list>(
33781+ self: Box<Self>,
33782+ post: &'p mut PostEntry,
33783+ ctx: &'p mut ListContext<'list>,
33784+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
33785+ if let Some(mut settings) = ctx.filter_settings.remove("AddSubjectTagPrefixSettings") {
33786+ let map = settings.as_object_mut().unwrap();
33787+ let enabled = serde_json::from_value::<bool>(map.remove("enabled").unwrap()).unwrap();
33788+ if !enabled {
33789+ trace!(
33790+ "AddSubjectTagPrefix is disabled from settings found for list.pk = {} \
33791+ skipping filter",
33792+ ctx.list.pk
33793+ );
33794+ return Ok((post, ctx));
33795+ }
33796+ }
33797+ trace!("Running AddSubjectTagPrefix filter");
33798+ let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
33799+ let mut subject;
33800+ if let Some((_, subj_val)) = headers.iter_mut().find(|(k, _)| k == HeaderName::SUBJECT) {
33801+ subject = format!("[{}] ", ctx.list.id).into_bytes();
33802+ subject.extend(subj_val.iter().cloned());
33803+ *subj_val = subject.as_slice();
33804+ } else {
33805+ subject = format!("[{}] (no subject)", ctx.list.id).into_bytes();
33806+ headers.push((HeaderName::SUBJECT, subject.as_slice()));
33807+ }
33808+
33809+ let mut new_vec = Vec::with_capacity(
33810+ headers
33811+ .iter()
33812+ .map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
33813+ .sum::<usize>()
33814+ + "\r\n\r\n".len()
33815+ + body.len(),
33816+ );
33817+ for (h, v) in headers {
33818+ new_vec.extend_from_slice(h.as_str().as_bytes());
33819+ new_vec.extend_from_slice(b": ");
33820+ new_vec.extend_from_slice(v);
33821+ new_vec.extend_from_slice(b"\r\n");
33822+ }
33823+ new_vec.extend_from_slice(b"\r\n\r\n");
33824+ new_vec.extend_from_slice(body);
33825+
33826+ post.bytes = new_vec;
33827+ Ok((post, ctx))
33828+ }
33829+ }
33830+
33831+ /// Adds `Archived-At` field, if configured.
33832+ pub struct ArchivedAtLink;
33833+ impl PostFilter for ArchivedAtLink {
33834+ fn feed<'p, 'list>(
33835+ self: Box<Self>,
33836+ post: &'p mut PostEntry,
33837+ ctx: &'p mut ListContext<'list>,
33838+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
33839+ let Some(mut settings) = ctx.filter_settings.remove("ArchivedAtLinkSettings") else {
33840+ trace!(
33841+ "No ArchivedAtLink settings found for list.pk = {} skipping filter",
33842+ ctx.list.pk
33843+ );
33844+ return Ok((post, ctx));
33845+ };
33846+ trace!("Running ArchivedAtLink filter");
33847+
33848+ let map = settings.as_object_mut().unwrap();
33849+ let template = serde_json::from_value::<String>(map.remove("template").unwrap()).unwrap();
33850+ let preserve_carets =
33851+ serde_json::from_value::<bool>(map.remove("preserve_carets").unwrap()).unwrap();
33852+
33853+ let env = minijinja::Environment::new();
33854+ let message_id = post.message_id.to_string();
33855+ let header_val = env
33856+ .render_named_str(
33857+ "ArchivedAtLinkSettings.template",
33858+ &template,
33859+ &if preserve_carets {
33860+ minijinja::context! {
33861+ msg_id => utf8_percent_encode(message_id.as_str(), PATH_SEGMENT).to_string()
33862+ }
33863+ } else {
33864+ minijinja::context! {
33865+ msg_id => utf8_percent_encode(message_id.as_str().strip_carets(), PATH_SEGMENT).to_string()
33866+ }
33867+ },
33868+ )
33869+ .map_err(|err| {
33870+ log::error!("ArchivedAtLink: {}", err);
33871+ })?;
33872+ let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
33873+ headers.push((HeaderName::ARCHIVED_AT, header_val.as_bytes()));
33874+
33875+ let mut new_vec = Vec::with_capacity(
33876+ headers
33877+ .iter()
33878+ .map(|(h, v)| h.as_str().as_bytes().len() + v.len() + ": \r\n".len())
33879+ .sum::<usize>()
33880+ + "\r\n\r\n".len()
33881+ + body.len(),
33882+ );
33883+ for (h, v) in headers {
33884+ new_vec.extend_from_slice(h.as_str().as_bytes());
33885+ new_vec.extend_from_slice(b": ");
33886+ new_vec.extend_from_slice(v);
33887+ new_vec.extend_from_slice(b"\r\n");
33888+ }
33889+ new_vec.extend_from_slice(b"\r\n\r\n");
33890+ new_vec.extend_from_slice(body);
33891+
33892+ post.bytes = new_vec;
33893+
33894+ Ok((post, ctx))
33895+ }
33896+ }
33897+
33898+ /// Assuming there are no more changes to be done on the post, it finalizes
33899+ /// which list subscriptions will receive the post in `post.action` field.
33900+ pub struct FinalizeRecipients;
33901+ impl PostFilter for FinalizeRecipients {
33902+ fn feed<'p, 'list>(
33903+ self: Box<Self>,
33904+ post: &'p mut PostEntry,
33905+ ctx: &'p mut ListContext<'list>,
33906+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
33907+ trace!("Running FinalizeRecipients filter");
33908+ let mut recipients = vec![];
33909+ let mut digests = vec![];
33910+ let email_from = post.from.get_email();
33911+ for subscription in ctx.subscriptions {
33912+ trace!("examining subscription {:?}", &subscription);
33913+ if subscription.address == email_from {
33914+ trace!("subscription is submitter");
33915+ }
33916+ if subscription.digest {
33917+ if subscription.address != email_from || subscription.receive_own_posts {
33918+ trace!("Subscription gets digest");
33919+ digests.push(subscription.address());
33920+ }
33921+ continue;
33922+ }
33923+ if subscription.address != email_from || subscription.receive_own_posts {
33924+ trace!("Subscription gets copy");
33925+ recipients.push(subscription.address());
33926+ }
33927+ }
33928+ ctx.scheduled_jobs.push(MailJob::Send { recipients });
33929+ if !digests.is_empty() {
33930+ ctx.scheduled_jobs.push(MailJob::StoreDigest {
33931+ recipients: digests,
33932+ });
33933+ }
33934+ post.action = PostAction::Accept;
33935+ Ok((post, ctx))
33936+ }
33937+ }
33938+
33939+ /// Allow specific MIMEs only.
33940+ pub struct MimeReject;
33941+
33942+ impl PostFilter for MimeReject {
33943+ fn feed<'p, 'list>(
33944+ self: Box<Self>,
33945+ post: &'p mut PostEntry,
33946+ ctx: &'p mut ListContext<'list>,
33947+ ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
33948+ let reject = if let Some(mut settings) = ctx.filter_settings.remove("MimeRejectSettings") {
33949+ let map = settings.as_object_mut().unwrap();
33950+ let enabled = serde_json::from_value::<bool>(map.remove("enabled").unwrap()).unwrap();
33951+ if !enabled {
33952+ trace!(
33953+ "MimeReject is disabled from settings found for list.pk = {} skipping filter",
33954+ ctx.list.pk
33955+ );
33956+ return Ok((post, ctx));
33957+ }
33958+ serde_json::from_value::<Vec<String>>(map.remove("reject").unwrap())
33959+ } else {
33960+ return Ok((post, ctx));
33961+ };
33962+ trace!("Running MimeReject filter with reject = {:?}", reject);
33963+ Ok((post, ctx))
33964+ }
33965+ }
33966 diff --git a/mailpot/src/message_filters/settings.rs b/mailpot/src/message_filters/settings.rs
33967new file mode 100644
33968index 0000000..bda6c09
33969--- /dev/null
33970+++ b/mailpot/src/message_filters/settings.rs
33971 @@ -0,0 +1,44 @@
33972+ /*
33973+ * This file is part of mailpot
33974+ *
33975+ * Copyright 2023 - Manos Pitsidianakis
33976+ *
33977+ * This program is free software: you can redistribute it and/or modify
33978+ * it under the terms of the GNU Affero General Public License as
33979+ * published by the Free Software Foundation, either version 3 of the
33980+ * License, or (at your option) any later version.
33981+ *
33982+ * This program is distributed in the hope that it will be useful,
33983+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
33984+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
33985+ * GNU Affero General Public License for more details.
33986+ *
33987+ * You should have received a copy of the GNU Affero General Public License
33988+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
33989+ */
33990+
33991+ //! Named templates, for generated e-mail like confirmations, alerts etc.
33992+ //!
33993+ //! Template database model: [`Template`](crate::Template).
33994+
33995+ use std::collections::HashMap;
33996+
33997+ use serde_json::Value;
33998+
33999+ use crate::{errors::*, Connection, DbVal};
34000+
34001+ impl Connection {
34002+ /// Get json settings.
34003+ pub fn get_settings(&self, list_pk: i64) -> Result<HashMap<String, DbVal<Value>>> {
34004+ let mut stmt = self.connection.prepare(
34005+ "SELECT pk, name, value FROM list_settings_json WHERE list = ? AND is_valid = 1;",
34006+ )?;
34007+ let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
34008+ let pk: i64 = row.get("pk")?;
34009+ let name: String = row.get("name")?;
34010+ let value: Value = row.get("value")?;
34011+ Ok((name, DbVal(value, pk)))
34012+ })?;
34013+ Ok(iter.collect::<std::result::Result<HashMap<String, DbVal<Value>>, rusqlite::Error>>()?)
34014+ }
34015+ }
34016 diff --git a/mailpot/src/migrations.rs.inc b/mailpot/src/migrations.rs.inc
34017new file mode 100644
34018index 0000000..aa1a2d6
34019--- /dev/null
34020+++ b/mailpot/src/migrations.rs.inc
34021 @@ -0,0 +1,277 @@
34022+
34023+ //(user_version, redo sql, undo sql
34024+ &[(1,r##"PRAGMA foreign_keys=ON;
34025+ ALTER TABLE templates RENAME TO template;"##,r##"PRAGMA foreign_keys=ON;
34026+ ALTER TABLE template RENAME TO templates;"##),(2,r##"PRAGMA foreign_keys=ON;
34027+ ALTER TABLE list ADD COLUMN topics JSON NOT NULL CHECK (json_type(topics) == 'array') DEFAULT '[]';"##,r##"PRAGMA foreign_keys=ON;
34028+ ALTER TABLE list DROP COLUMN topics;"##),(3,r##"PRAGMA foreign_keys=ON;
34029+
34030+ UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk;
34031+
34032+ CREATE TRIGGER
34033+ IF NOT EXISTS sort_topics_update_trigger
34034+ AFTER UPDATE ON list
34035+ FOR EACH ROW
34036+ WHEN NEW.topics != OLD.topics
34037+ BEGIN
34038+ UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
34039+ END;
34040+
34041+ CREATE TRIGGER
34042+ IF NOT EXISTS sort_topics_new_trigger
34043+ AFTER INSERT ON list
34044+ FOR EACH ROW
34045+ BEGIN
34046+ UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
34047+ END;"##,r##"PRAGMA foreign_keys=ON;
34048+
34049+ DROP TRIGGER sort_topics_update_trigger;
34050+ DROP TRIGGER sort_topics_new_trigger;"##),(4,r##"CREATE TABLE IF NOT EXISTS settings_json_schema (
34051+ pk INTEGER PRIMARY KEY NOT NULL,
34052+ id TEXT NOT NULL UNIQUE,
34053+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
34054+ created INTEGER NOT NULL DEFAULT (unixepoch()),
34055+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
34056+ );
34057+
34058+ CREATE TABLE IF NOT EXISTS list_settings_json (
34059+ pk INTEGER PRIMARY KEY NOT NULL,
34060+ name TEXT NOT NULL,
34061+ list INTEGER,
34062+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
34063+ is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
34064+ created INTEGER NOT NULL DEFAULT (unixepoch()),
34065+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
34066+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
34067+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
34068+ UNIQUE (list, name) ON CONFLICT ROLLBACK
34069+ );
34070+
34071+ CREATE TRIGGER
34072+ IF NOT EXISTS is_valid_settings_json_on_update
34073+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
34074+ FOR EACH ROW
34075+ BEGIN
34076+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
34077+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
34078+ END;
34079+
34080+ CREATE TRIGGER
34081+ IF NOT EXISTS is_valid_settings_json_on_insert
34082+ AFTER INSERT ON list_settings_json
34083+ FOR EACH ROW
34084+ BEGIN
34085+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
34086+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
34087+ END;
34088+
34089+ CREATE TRIGGER
34090+ IF NOT EXISTS invalidate_settings_json_on_schema_update
34091+ AFTER UPDATE OF value, id ON settings_json_schema
34092+ FOR EACH ROW
34093+ BEGIN
34094+ UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
34095+ END;
34096+
34097+ DROP TRIGGER IF EXISTS last_modified_list;
34098+ DROP TRIGGER IF EXISTS last_modified_owner;
34099+ DROP TRIGGER IF EXISTS last_modified_post_policy;
34100+ DROP TRIGGER IF EXISTS last_modified_subscription_policy;
34101+ DROP TRIGGER IF EXISTS last_modified_subscription;
34102+ DROP TRIGGER IF EXISTS last_modified_account;
34103+ DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
34104+ DROP TRIGGER IF EXISTS last_modified_template;
34105+ DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
34106+ DROP TRIGGER IF EXISTS last_modified_list_settings_json;
34107+
34108+ -- [tag:last_modified_list]: update last_modified on every change.
34109+ CREATE TRIGGER
34110+ IF NOT EXISTS last_modified_list
34111+ AFTER UPDATE ON list
34112+ FOR EACH ROW
34113+ WHEN NEW.last_modified == OLD.last_modified
34114+ BEGIN
34115+ UPDATE list SET last_modified = unixepoch()
34116+ WHERE pk = NEW.pk;
34117+ END;
34118+
34119+ -- [tag:last_modified_owner]: update last_modified on every change.
34120+ CREATE TRIGGER
34121+ IF NOT EXISTS last_modified_owner
34122+ AFTER UPDATE ON owner
34123+ FOR EACH ROW
34124+ WHEN NEW.last_modified == OLD.last_modified
34125+ BEGIN
34126+ UPDATE owner SET last_modified = unixepoch()
34127+ WHERE pk = NEW.pk;
34128+ END;
34129+
34130+ -- [tag:last_modified_post_policy]: update last_modified on every change.
34131+ CREATE TRIGGER
34132+ IF NOT EXISTS last_modified_post_policy
34133+ AFTER UPDATE ON post_policy
34134+ FOR EACH ROW
34135+ WHEN NEW.last_modified == OLD.last_modified
34136+ BEGIN
34137+ UPDATE post_policy SET last_modified = unixepoch()
34138+ WHERE pk = NEW.pk;
34139+ END;
34140+
34141+ -- [tag:last_modified_subscription_policy]: update last_modified on every change.
34142+ CREATE TRIGGER
34143+ IF NOT EXISTS last_modified_subscription_policy
34144+ AFTER UPDATE ON subscription_policy
34145+ FOR EACH ROW
34146+ WHEN NEW.last_modified == OLD.last_modified
34147+ BEGIN
34148+ UPDATE subscription_policy SET last_modified = unixepoch()
34149+ WHERE pk = NEW.pk;
34150+ END;
34151+
34152+ -- [tag:last_modified_subscription]: update last_modified on every change.
34153+ CREATE TRIGGER
34154+ IF NOT EXISTS last_modified_subscription
34155+ AFTER UPDATE ON subscription
34156+ FOR EACH ROW
34157+ WHEN NEW.last_modified == OLD.last_modified
34158+ BEGIN
34159+ UPDATE subscription SET last_modified = unixepoch()
34160+ WHERE pk = NEW.pk;
34161+ END;
34162+
34163+ -- [tag:last_modified_account]: update last_modified on every change.
34164+ CREATE TRIGGER
34165+ IF NOT EXISTS last_modified_account
34166+ AFTER UPDATE ON account
34167+ FOR EACH ROW
34168+ WHEN NEW.last_modified == OLD.last_modified
34169+ BEGIN
34170+ UPDATE account SET last_modified = unixepoch()
34171+ WHERE pk = NEW.pk;
34172+ END;
34173+
34174+ -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
34175+ CREATE TRIGGER
34176+ IF NOT EXISTS last_modified_candidate_subscription
34177+ AFTER UPDATE ON candidate_subscription
34178+ FOR EACH ROW
34179+ WHEN NEW.last_modified == OLD.last_modified
34180+ BEGIN
34181+ UPDATE candidate_subscription SET last_modified = unixepoch()
34182+ WHERE pk = NEW.pk;
34183+ END;
34184+
34185+ -- [tag:last_modified_template]: update last_modified on every change.
34186+ CREATE TRIGGER
34187+ IF NOT EXISTS last_modified_template
34188+ AFTER UPDATE ON template
34189+ FOR EACH ROW
34190+ WHEN NEW.last_modified == OLD.last_modified
34191+ BEGIN
34192+ UPDATE template SET last_modified = unixepoch()
34193+ WHERE pk = NEW.pk;
34194+ END;
34195+
34196+ -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
34197+ CREATE TRIGGER
34198+ IF NOT EXISTS last_modified_settings_json_schema
34199+ AFTER UPDATE ON settings_json_schema
34200+ FOR EACH ROW
34201+ WHEN NEW.last_modified == OLD.last_modified
34202+ BEGIN
34203+ UPDATE settings_json_schema SET last_modified = unixepoch()
34204+ WHERE pk = NEW.pk;
34205+ END;
34206+
34207+ -- [tag:last_modified_list_settings_json]: update last_modified on every change.
34208+ CREATE TRIGGER
34209+ IF NOT EXISTS last_modified_list_settings_json
34210+ AFTER UPDATE ON list_settings_json
34211+ FOR EACH ROW
34212+ WHEN NEW.last_modified == OLD.last_modified
34213+ BEGIN
34214+ UPDATE list_settings_json SET last_modified = unixepoch()
34215+ WHERE pk = NEW.pk;
34216+ END;"##,r##"DROP TABLE settings_json_schema;
34217+ DROP TABLE list_settings_json;"##),(5,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
34218+ "$schema": "http://json-schema.org/draft-07/schema",
34219+ "$ref": "#/$defs/ArchivedAtLinkSettings",
34220+ "$defs": {
34221+ "ArchivedAtLinkSettings": {
34222+ "title": "ArchivedAtLinkSettings",
34223+ "description": "Settings for ArchivedAtLink message filter",
34224+ "type": "object",
34225+ "properties": {
34226+ "template": {
34227+ "title": "Jinja template for header value",
34228+ "description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
34229+ "examples": [
34230+ "https://www.example.com/{{msg_id}}",
34231+ "https://www.example.com/{{msg_id}}.html"
34232+ ],
34233+ "type": "string",
34234+ "pattern": ".+[{][{]msg_id[}][}].*"
34235+ },
34236+ "preserve_carets": {
34237+ "title": "Preserve carets of `Message-ID` in generated value",
34238+ "type": "boolean",
34239+ "default": false
34240+ }
34241+ },
34242+ "required": [
34243+ "template"
34244+ ]
34245+ }
34246+ }
34247+ }');"##,r##"DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';"##),(6,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
34248+ "$schema": "http://json-schema.org/draft-07/schema",
34249+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
34250+ "$defs": {
34251+ "AddSubjectTagPrefixSettings": {
34252+ "title": "AddSubjectTagPrefixSettings",
34253+ "description": "Settings for AddSubjectTagPrefix message filter",
34254+ "type": "object",
34255+ "properties": {
34256+ "enabled": {
34257+ "title": "If true, the list subject prefix is added to post subjects.",
34258+ "type": "boolean"
34259+ }
34260+ },
34261+ "required": [
34262+ "enabled"
34263+ ]
34264+ }
34265+ }
34266+ }');"##,r##"DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';"##),(7,r##"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
34267+ "$schema": "http://json-schema.org/draft-07/schema",
34268+ "$ref": "#/$defs/MimeRejectSettings",
34269+ "$defs": {
34270+ "MimeRejectSettings": {
34271+ "title": "MimeRejectSettings",
34272+ "description": "Settings for MimeReject message filter",
34273+ "type": "object",
34274+ "properties": {
34275+ "enabled": {
34276+ "title": "If true, list posts that contain mime types in the reject array are rejected.",
34277+ "type": "boolean"
34278+ },
34279+ "reject": {
34280+ "title": "Mime types to reject.",
34281+ "type": "array",
34282+ "minLength": 0,
34283+ "items": { "$ref": "#/$defs/MimeType" }
34284+ },
34285+ "required": [
34286+ "enabled"
34287+ ]
34288+ }
34289+ },
34290+ "MimeType": {
34291+ "type": "string",
34292+ "maxLength": 127,
34293+ "minLength": 3,
34294+ "uniqueItems": true,
34295+ "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
34296+ }
34297+ }
34298+ }');"##,r##"DELETE FROM settings_json_schema WHERE id = 'MimeRejectSettings';"##),]
34299\ No newline at end of file
34300 diff --git a/mailpot/src/models.rs b/mailpot/src/models.rs
34301new file mode 100644
34302index 0000000..884c966
34303--- /dev/null
34304+++ b/mailpot/src/models.rs
34305 @@ -0,0 +1,746 @@
34306+ /*
34307+ * This file is part of mailpot
34308+ *
34309+ * Copyright 2020 - Manos Pitsidianakis
34310+ *
34311+ * This program is free software: you can redistribute it and/or modify
34312+ * it under the terms of the GNU Affero General Public License as
34313+ * published by the Free Software Foundation, either version 3 of the
34314+ * License, or (at your option) any later version.
34315+ *
34316+ * This program is distributed in the hope that it will be useful,
34317+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
34318+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
34319+ * GNU Affero General Public License for more details.
34320+ *
34321+ * You should have received a copy of the GNU Affero General Public License
34322+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
34323+ */
34324+
34325+ //! Database models: [`MailingList`], [`ListOwner`], [`ListSubscription`],
34326+ //! [`PostPolicy`], [`SubscriptionPolicy`] and [`Post`].
34327+
34328+ use super::*;
34329+ pub mod changesets;
34330+
34331+ use std::borrow::Cow;
34332+
34333+ use melib::email::Address;
34334+
34335+ /// A database entry and its primary key. Derefs to its inner type.
34336+ ///
34337+ /// # Example
34338+ ///
34339+ /// ```rust,no_run
34340+ /// # use mailpot::{*, models::*};
34341+ /// # fn foo(db: &Connection) {
34342+ /// let val: Option<DbVal<MailingList>> = db.list(5).unwrap();
34343+ /// if let Some(list) = val {
34344+ /// assert_eq!(list.pk(), 5);
34345+ /// }
34346+ /// # }
34347+ /// ```
34348+ #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
34349+ #[serde(transparent)]
34350+ pub struct DbVal<T: Send + Sync>(pub T, #[serde(skip)] pub i64);
34351+
34352+ impl<T: Send + Sync> DbVal<T> {
34353+ /// Primary key.
34354+ #[inline(always)]
34355+ pub fn pk(&self) -> i64 {
34356+ self.1
34357+ }
34358+
34359+ /// Unwrap inner value.
34360+ #[inline(always)]
34361+ pub fn into_inner(self) -> T {
34362+ self.0
34363+ }
34364+ }
34365+
34366+ impl<T> std::borrow::Borrow<T> for DbVal<T>
34367+ where
34368+ T: Send + Sync + Sized,
34369+ {
34370+ fn borrow(&self) -> &T {
34371+ &self.0
34372+ }
34373+ }
34374+
34375+ impl<T> std::ops::Deref for DbVal<T>
34376+ where
34377+ T: Send + Sync,
34378+ {
34379+ type Target = T;
34380+ fn deref(&self) -> &T {
34381+ &self.0
34382+ }
34383+ }
34384+
34385+ impl<T> std::ops::DerefMut for DbVal<T>
34386+ where
34387+ T: Send + Sync,
34388+ {
34389+ fn deref_mut(&mut self) -> &mut Self::Target {
34390+ &mut self.0
34391+ }
34392+ }
34393+
34394+ impl<T> std::fmt::Display for DbVal<T>
34395+ where
34396+ T: std::fmt::Display + Send + Sync,
34397+ {
34398+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34399+ write!(fmt, "{}", self.0)
34400+ }
34401+ }
34402+
34403+ /// A mailing list entry.
34404+ #[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
34405+ pub struct MailingList {
34406+ /// Database primary key.
34407+ pub pk: i64,
34408+ /// Mailing list name.
34409+ pub name: String,
34410+ /// Mailing list ID (what appears in the subject tag, e.g. `[mailing-list]
34411+ /// New post!`).
34412+ pub id: String,
34413+ /// Mailing list e-mail address.
34414+ pub address: String,
34415+ /// Discussion topics.
34416+ pub topics: Vec<String>,
34417+ /// Mailing list description.
34418+ pub description: Option<String>,
34419+ /// Mailing list archive URL.
34420+ pub archive_url: Option<String>,
34421+ }
34422+
34423+ impl std::fmt::Display for MailingList {
34424+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34425+ if let Some(description) = self.description.as_ref() {
34426+ write!(
34427+ fmt,
34428+ "[#{} {}] {} <{}>: {}",
34429+ self.pk, self.id, self.name, self.address, description
34430+ )
34431+ } else {
34432+ write!(
34433+ fmt,
34434+ "[#{} {}] {} <{}>",
34435+ self.pk, self.id, self.name, self.address
34436+ )
34437+ }
34438+ }
34439+ }
34440+
34441+ impl MailingList {
34442+ /// Mailing list display name.
34443+ ///
34444+ /// # Example
34445+ ///
34446+ /// ```rust
34447+ /// # fn main() -> mailpot::Result<()> {
34448+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34449+ /// assert_eq!(
34450+ /// &list.display_name(),
34451+ /// "\"foobar chat\" <foo-chat@example.com>"
34452+ /// );
34453+ /// # Ok(())
34454+ /// # }
34455+ pub fn display_name(&self) -> String {
34456+ format!("\"{}\" <{}>", self.name, self.address)
34457+ }
34458+
34459+ #[inline]
34460+ /// Request subaddress.
34461+ ///
34462+ /// # Example
34463+ ///
34464+ /// ```rust
34465+ /// # fn main() -> mailpot::Result<()> {
34466+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34467+ /// assert_eq!(&list.request_subaddr(), "foo-chat+request@example.com");
34468+ /// # Ok(())
34469+ /// # }
34470+ pub fn request_subaddr(&self) -> String {
34471+ let p = self.address.split('@').collect::<Vec<&str>>();
34472+ format!("{}+request@{}", p[0], p[1])
34473+ }
34474+
34475+ /// Value of `List-Id` header.
34476+ ///
34477+ /// See RFC2919 Section 3: <https://www.rfc-editor.org/rfc/rfc2919>
34478+ ///
34479+ /// # Example
34480+ ///
34481+ /// ```rust
34482+ /// # fn main() -> mailpot::Result<()> {
34483+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34484+ /// assert_eq!(
34485+ /// &list.id_header(),
34486+ /// "Hello world, from foo-chat list <foo-chat.example.com>");
34487+ /// # Ok(())
34488+ /// # }
34489+ pub fn id_header(&self) -> String {
34490+ let p = self.address.split('@').collect::<Vec<&str>>();
34491+ format!(
34492+ "{}{}<{}.{}>",
34493+ self.description.as_deref().unwrap_or(""),
34494+ self.description.as_ref().map(|_| " ").unwrap_or(""),
34495+ self.id,
34496+ p[1]
34497+ )
34498+ }
34499+
34500+ /// Value of `List-Help` header.
34501+ ///
34502+ /// See RFC2369 Section 3.1: <https://www.rfc-editor.org/rfc/rfc2369#section-3.1>
34503+ ///
34504+ /// # Example
34505+ ///
34506+ /// ```rust
34507+ /// # fn main() -> mailpot::Result<()> {
34508+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34509+ /// assert_eq!(
34510+ /// &list.help_header().unwrap(),
34511+ /// "<mailto:foo-chat+request@example.com?subject=help>"
34512+ /// );
34513+ /// # Ok(())
34514+ /// # }
34515+ pub fn help_header(&self) -> Option<String> {
34516+ Some(format!("<mailto:{}?subject=help>", self.request_subaddr()))
34517+ }
34518+
34519+ /// Value of `List-Post` header.
34520+ ///
34521+ /// See RFC2369 Section 3.4: <https://www.rfc-editor.org/rfc/rfc2369#section-3.4>
34522+ ///
34523+ /// # Example
34524+ ///
34525+ /// ```rust
34526+ /// # fn main() -> mailpot::Result<()> {
34527+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34528+ /// assert_eq!(&list.post_header(None).unwrap(), "NO");
34529+ /// assert_eq!(
34530+ /// &list.post_header(Some(&post_policy)).unwrap(),
34531+ /// "<mailto:foo-chat@example.com>"
34532+ /// );
34533+ /// # Ok(())
34534+ /// # }
34535+ pub fn post_header(&self, policy: Option<&PostPolicy>) -> Option<String> {
34536+ Some(policy.map_or_else(
34537+ || "NO".to_string(),
34538+ |p| {
34539+ if p.announce_only {
34540+ "NO".to_string()
34541+ } else {
34542+ format!("<mailto:{}>", self.address)
34543+ }
34544+ },
34545+ ))
34546+ }
34547+
34548+ /// Value of `List-Unsubscribe` header.
34549+ ///
34550+ /// See RFC2369 Section 3.2: <https://www.rfc-editor.org/rfc/rfc2369#section-3.2>
34551+ ///
34552+ /// # Example
34553+ ///
34554+ /// ```rust
34555+ /// # fn main() -> mailpot::Result<()> {
34556+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34557+ /// assert_eq!(
34558+ /// &list.unsubscribe_header(Some(&sub_policy)).unwrap(),
34559+ /// "<mailto:foo-chat+request@example.com?subject=unsubscribe>"
34560+ /// );
34561+ /// # Ok(())
34562+ /// # }
34563+ pub fn unsubscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
34564+ policy.map_or_else(
34565+ || None,
34566+ |_| {
34567+ Some(format!(
34568+ "<mailto:{}?subject=unsubscribe>",
34569+ self.request_subaddr()
34570+ ))
34571+ },
34572+ )
34573+ }
34574+
34575+ /// Value of `List-Subscribe` header.
34576+ ///
34577+ /// See RFC2369 Section 3.3: <https://www.rfc-editor.org/rfc/rfc2369#section-3.3>
34578+ ///
34579+ /// # Example
34580+ ///
34581+ /// ```rust
34582+ /// # fn main() -> mailpot::Result<()> {
34583+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34584+ /// assert_eq!(
34585+ /// &list.subscribe_header(Some(&sub_policy)).unwrap(),
34586+ /// "<mailto:foo-chat+request@example.com?subject=subscribe>",
34587+ /// );
34588+ /// # Ok(())
34589+ /// # }
34590+ /// ```
34591+ pub fn subscribe_header(&self, policy: Option<&SubscriptionPolicy>) -> Option<String> {
34592+ policy.map_or_else(
34593+ || None,
34594+ |_| {
34595+ Some(format!(
34596+ "<mailto:{}?subject=subscribe>",
34597+ self.request_subaddr()
34598+ ))
34599+ },
34600+ )
34601+ }
34602+
34603+ /// Value of `List-Archive` header.
34604+ ///
34605+ /// See RFC2369 Section 3.6: <https://www.rfc-editor.org/rfc/rfc2369#section-3.6>
34606+ ///
34607+ /// # Example
34608+ ///
34609+ /// ```rust
34610+ /// # fn main() -> mailpot::Result<()> {
34611+ #[doc = include_str!("./doctests/db_setup.rs.inc")]
34612+ /// assert_eq!(
34613+ /// &list.archive_header().unwrap(),
34614+ /// "<https://lists.example.com>"
34615+ /// );
34616+ /// # Ok(())
34617+ /// # }
34618+ /// ```
34619+ pub fn archive_header(&self) -> Option<String> {
34620+ self.archive_url.as_ref().map(|url| format!("<{}>", url))
34621+ }
34622+
34623+ /// List address as a [`melib::Address`]
34624+ pub fn address(&self) -> Address {
34625+ Address::new(Some(self.name.clone()), self.address.clone())
34626+ }
34627+
34628+ /// List unsubscribe action as a [`MailtoAddress`].
34629+ pub fn unsubscription_mailto(&self) -> MailtoAddress {
34630+ MailtoAddress {
34631+ address: self.request_subaddr(),
34632+ subject: Some("unsubscribe".to_string()),
34633+ }
34634+ }
34635+
34636+ /// List subscribe action as a [`MailtoAddress`].
34637+ pub fn subscription_mailto(&self) -> MailtoAddress {
34638+ MailtoAddress {
34639+ address: self.request_subaddr(),
34640+ subject: Some("subscribe".to_string()),
34641+ }
34642+ }
34643+
34644+ /// List owner as a [`MailtoAddress`].
34645+ pub fn owner_mailto(&self) -> MailtoAddress {
34646+ let p = self.address.split('@').collect::<Vec<&str>>();
34647+ MailtoAddress {
34648+ address: format!("{}+owner@{}", p[0], p[1]),
34649+ subject: None,
34650+ }
34651+ }
34652+
34653+ /// List archive url value.
34654+ pub fn archive_url(&self) -> Option<&str> {
34655+ self.archive_url.as_deref()
34656+ }
34657+
34658+ /// Insert all available list headers.
34659+ pub fn insert_headers(
34660+ &self,
34661+ draft: &mut melib::Draft,
34662+ post_policy: Option<&PostPolicy>,
34663+ subscription_policy: Option<&SubscriptionPolicy>,
34664+ ) {
34665+ for (hdr, val) in [
34666+ ("List-Id", Some(self.id_header())),
34667+ ("List-Help", self.help_header()),
34668+ ("List-Post", self.post_header(post_policy)),
34669+ (
34670+ "List-Unsubscribe",
34671+ self.unsubscribe_header(subscription_policy),
34672+ ),
34673+ ("List-Subscribe", self.subscribe_header(subscription_policy)),
34674+ ("List-Archive", self.archive_header()),
34675+ ] {
34676+ if let Some(val) = val {
34677+ draft
34678+ .headers
34679+ .insert(melib::HeaderName::try_from(hdr).unwrap(), val);
34680+ }
34681+ }
34682+ }
34683+
34684+ /// Generate help e-mail body containing information on how to subscribe,
34685+ /// unsubscribe, post and how to contact the list owners.
34686+ pub fn generate_help_email(
34687+ &self,
34688+ post_policy: Option<&PostPolicy>,
34689+ subscription_policy: Option<&SubscriptionPolicy>,
34690+ ) -> String {
34691+ format!(
34692+ "Help for {list_name}\n\n{subscribe}\n\n{post}\n\nTo contact the list owners, send an \
34693+ e-mail to {contact}\n",
34694+ list_name = self.name,
34695+ subscribe = subscription_policy.map_or(
34696+ Cow::Borrowed("This list is not open to subscriptions."),
34697+ |p| if p.open {
34698+ Cow::Owned(format!(
34699+ "Anyone can subscribe without restrictions. Send an e-mail to {} with the \
34700+ subject `subscribe`.",
34701+ self.request_subaddr(),
34702+ ))
34703+ } else if p.manual {
34704+ Cow::Borrowed(
34705+ "The list owners must manually add you to the list of subscriptions.",
34706+ )
34707+ } else if p.request {
34708+ Cow::Owned(format!(
34709+ "Anyone can request to subscribe. Send an e-mail to {} with the subject \
34710+ `subscribe` and a confirmation will be sent to you when your request is \
34711+ approved.",
34712+ self.request_subaddr(),
34713+ ))
34714+ } else {
34715+ Cow::Borrowed("Please contact the list owners for details on how to subscribe.")
34716+ }
34717+ ),
34718+ post = post_policy.map_or(Cow::Borrowed("This list does not allow posting."), |p| {
34719+ if p.announce_only {
34720+ Cow::Borrowed(
34721+ "This list is announce only, which means that you can only receive posts \
34722+ from the list owners.",
34723+ )
34724+ } else if p.subscription_only {
34725+ Cow::Owned(format!(
34726+ "Only list subscriptions can post to this list. Send your post to {}",
34727+ self.address
34728+ ))
34729+ } else if p.approval_needed {
34730+ Cow::Owned(format!(
34731+ "Anyone can post, but approval from list owners is required if they are \
34732+ not subscribed. Send your post to {}",
34733+ self.address
34734+ ))
34735+ } else {
34736+ Cow::Borrowed("This list does not allow posting.")
34737+ }
34738+ }),
34739+ contact = self.owner_mailto().address,
34740+ )
34741+ }
34742+
34743+ /// Utility function to get a `Vec<String>` -which is the expected type of
34744+ /// the `topics` field- from a `serde_json::Value`, which is the value
34745+ /// stored in the `topics` column in `sqlite3`.
34746+ ///
34747+ /// # Example
34748+ ///
34749+ /// ```rust
34750+ /// # use mailpot::models::MailingList;
34751+ /// use serde_json::Value;
34752+ ///
34753+ /// # fn main() -> Result<(), serde_json::Error> {
34754+ /// let value: Value = serde_json::from_str(r#"["fruits","vegetables"]"#)?;
34755+ /// assert_eq!(
34756+ /// MailingList::topics_from_json_value(value),
34757+ /// Ok(vec!["fruits".to_string(), "vegetables".to_string()])
34758+ /// );
34759+ ///
34760+ /// let value: Value = serde_json::from_str(r#"{"invalid":"value"}"#)?;
34761+ /// assert!(MailingList::topics_from_json_value(value).is_err());
34762+ /// # Ok(())
34763+ /// # }
34764+ /// ```
34765+ pub fn topics_from_json_value(
34766+ v: serde_json::Value,
34767+ ) -> std::result::Result<Vec<String>, rusqlite::Error> {
34768+ let err_fn = || {
34769+ rusqlite::Error::FromSqlConversionFailure(
34770+ 8,
34771+ rusqlite::types::Type::Text,
34772+ anyhow::Error::msg(
34773+ "topics column must be a json array of strings serialized as a string, e.g. \
34774+ \"[]\" or \"['topicA', 'topicB']\"",
34775+ )
34776+ .into(),
34777+ )
34778+ };
34779+ v.as_array()
34780+ .map(|arr| {
34781+ arr.iter()
34782+ .map(|v| v.as_str().map(str::to_string))
34783+ .collect::<Option<Vec<String>>>()
34784+ })
34785+ .ok_or_else(err_fn)?
34786+ .ok_or_else(err_fn)
34787+ }
34788+ }
34789+
34790+ /// A mailing list subscription entry.
34791+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
34792+ pub struct ListSubscription {
34793+ /// Database primary key.
34794+ pub pk: i64,
34795+ /// Mailing list foreign key (See [`MailingList`]).
34796+ pub list: i64,
34797+ /// Subscription's e-mail address.
34798+ pub address: String,
34799+ /// Subscription's name, optional.
34800+ pub name: Option<String>,
34801+ /// Subscription's account foreign key, optional.
34802+ pub account: Option<i64>,
34803+ /// Whether this subscription is enabled.
34804+ pub enabled: bool,
34805+ /// Whether the e-mail address is verified.
34806+ pub verified: bool,
34807+ /// Whether subscription wishes to receive list posts as a periodical digest
34808+ /// e-mail.
34809+ pub digest: bool,
34810+ /// Whether subscription wishes their e-mail address hidden from public
34811+ /// view.
34812+ pub hide_address: bool,
34813+ /// Whether subscription wishes to receive mailing list post duplicates,
34814+ /// i.e. posts addressed to them and the mailing list to which they are
34815+ /// subscribed.
34816+ pub receive_duplicates: bool,
34817+ /// Whether subscription wishes to receive their own mailing list posts from
34818+ /// the mailing list, as a confirmation.
34819+ pub receive_own_posts: bool,
34820+ /// Whether subscription wishes to receive a plain confirmation for their
34821+ /// own mailing list posts.
34822+ pub receive_confirmation: bool,
34823+ }
34824+
34825+ impl std::fmt::Display for ListSubscription {
34826+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34827+ write!(
34828+ fmt,
34829+ "{} [digest: {}, hide_address: {} verified: {} {}]",
34830+ self.address(),
34831+ self.digest,
34832+ self.hide_address,
34833+ self.verified,
34834+ if self.enabled {
34835+ "enabled"
34836+ } else {
34837+ "not enabled"
34838+ },
34839+ )
34840+ }
34841+ }
34842+
34843+ impl ListSubscription {
34844+ /// Subscription address as a [`melib::Address`]
34845+ pub fn address(&self) -> Address {
34846+ Address::new(self.name.clone(), self.address.clone())
34847+ }
34848+ }
34849+
34850+ /// A mailing list post policy entry.
34851+ ///
34852+ /// Only one of the boolean flags must be set to true.
34853+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
34854+ pub struct PostPolicy {
34855+ /// Database primary key.
34856+ pub pk: i64,
34857+ /// Mailing list foreign key (See [`MailingList`]).
34858+ pub list: i64,
34859+ /// Whether the policy is announce only (Only list owners can submit posts,
34860+ /// and everyone will receive them).
34861+ pub announce_only: bool,
34862+ /// Whether the policy is "subscription only" (Only list subscriptions can
34863+ /// post).
34864+ pub subscription_only: bool,
34865+ /// Whether the policy is "approval needed" (Anyone can post, but approval
34866+ /// from list owners is required if they are not subscribed).
34867+ pub approval_needed: bool,
34868+ /// Whether the policy is "open" (Anyone can post, but approval from list
34869+ /// owners is required. Subscriptions are not enabled).
34870+ pub open: bool,
34871+ /// Custom policy.
34872+ pub custom: bool,
34873+ }
34874+
34875+ impl std::fmt::Display for PostPolicy {
34876+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34877+ write!(fmt, "{:?}", self)
34878+ }
34879+ }
34880+
34881+ /// A mailing list owner entry.
34882+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
34883+ pub struct ListOwner {
34884+ /// Database primary key.
34885+ pub pk: i64,
34886+ /// Mailing list foreign key (See [`MailingList`]).
34887+ pub list: i64,
34888+ /// Mailing list owner e-mail address.
34889+ pub address: String,
34890+ /// Mailing list owner name, optional.
34891+ pub name: Option<String>,
34892+ }
34893+
34894+ impl std::fmt::Display for ListOwner {
34895+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34896+ write!(fmt, "[#{} {}] {}", self.pk, self.list, self.address())
34897+ }
34898+ }
34899+
34900+ impl From<ListOwner> for ListSubscription {
34901+ fn from(val: ListOwner) -> Self {
34902+ Self {
34903+ pk: 0,
34904+ list: val.list,
34905+ address: val.address,
34906+ name: val.name,
34907+ account: None,
34908+ digest: false,
34909+ hide_address: false,
34910+ receive_duplicates: true,
34911+ receive_own_posts: false,
34912+ receive_confirmation: true,
34913+ enabled: true,
34914+ verified: true,
34915+ }
34916+ }
34917+ }
34918+
34919+ impl ListOwner {
34920+ /// Owner address as a [`melib::Address`]
34921+ pub fn address(&self) -> Address {
34922+ Address::new(self.name.clone(), self.address.clone())
34923+ }
34924+ }
34925+
34926+ /// A mailing list post entry.
34927+ #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
34928+ pub struct Post {
34929+ /// Database primary key.
34930+ pub pk: i64,
34931+ /// Mailing list foreign key (See [`MailingList`]).
34932+ pub list: i64,
34933+ /// Envelope `From` of post.
34934+ pub envelope_from: Option<String>,
34935+ /// `From` header address of post.
34936+ pub address: String,
34937+ /// `Message-ID` header value of post.
34938+ pub message_id: String,
34939+ /// Post as bytes.
34940+ pub message: Vec<u8>,
34941+ /// Unix timestamp of date.
34942+ pub timestamp: u64,
34943+ /// Date header as string.
34944+ pub datetime: String,
34945+ /// Month-year as a `YYYY-mm` formatted string, for use in archives.
34946+ pub month_year: String,
34947+ }
34948+
34949+ impl std::fmt::Debug for Post {
34950+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34951+ fmt.debug_struct(stringify!(Post))
34952+ .field("pk", &self.pk)
34953+ .field("list", &self.list)
34954+ .field("envelope_from", &self.envelope_from)
34955+ .field("address", &self.address)
34956+ .field("message_id", &self.message_id)
34957+ .field("message", &String::from_utf8_lossy(&self.message))
34958+ .field("timestamp", &self.timestamp)
34959+ .field("datetime", &self.datetime)
34960+ .field("month_year", &self.month_year)
34961+ .finish()
34962+ }
34963+ }
34964+
34965+ impl std::fmt::Display for Post {
34966+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34967+ write!(fmt, "{:?}", self)
34968+ }
34969+ }
34970+
34971+ /// A mailing list subscription policy entry.
34972+ ///
34973+ /// Only one of the policy boolean flags must be set to true.
34974+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
34975+ pub struct SubscriptionPolicy {
34976+ /// Database primary key.
34977+ pub pk: i64,
34978+ /// Mailing list foreign key (See [`MailingList`]).
34979+ pub list: i64,
34980+ /// Send confirmation e-mail when subscription is finalized.
34981+ pub send_confirmation: bool,
34982+ /// Anyone can subscribe without restrictions.
34983+ pub open: bool,
34984+ /// Only list owners can manually add subscriptions.
34985+ pub manual: bool,
34986+ /// Anyone can request to subscribe.
34987+ pub request: bool,
34988+ /// Allow subscriptions, but handle it manually.
34989+ pub custom: bool,
34990+ }
34991+
34992+ impl std::fmt::Display for SubscriptionPolicy {
34993+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
34994+ write!(fmt, "{:?}", self)
34995+ }
34996+ }
34997+
34998+ /// An account entry.
34999+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
35000+ pub struct Account {
35001+ /// Database primary key.
35002+ pub pk: i64,
35003+ /// Accounts's display name, optional.
35004+ pub name: Option<String>,
35005+ /// Account's e-mail address.
35006+ pub address: String,
35007+ /// GPG public key.
35008+ pub public_key: Option<String>,
35009+ /// SSH public key.
35010+ pub password: String,
35011+ /// Whether this account is enabled.
35012+ pub enabled: bool,
35013+ }
35014+
35015+ impl std::fmt::Display for Account {
35016+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
35017+ write!(fmt, "{:?}", self)
35018+ }
35019+ }
35020+
35021+ /// A mailing list subscription candidate.
35022+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
35023+ pub struct ListCandidateSubscription {
35024+ /// Database primary key.
35025+ pub pk: i64,
35026+ /// Mailing list foreign key (See [`MailingList`]).
35027+ pub list: i64,
35028+ /// Subscription's e-mail address.
35029+ pub address: String,
35030+ /// Subscription's name, optional.
35031+ pub name: Option<String>,
35032+ /// Accepted, foreign key on [`ListSubscription`].
35033+ pub accepted: Option<i64>,
35034+ }
35035+
35036+ impl ListCandidateSubscription {
35037+ /// Subscription request address as a [`melib::Address`]
35038+ pub fn address(&self) -> Address {
35039+ Address::new(self.name.clone(), self.address.clone())
35040+ }
35041+ }
35042+
35043+ impl std::fmt::Display for ListCandidateSubscription {
35044+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
35045+ write!(
35046+ fmt,
35047+ "List_pk: {} name: {:?} address: {} accepted: {:?}",
35048+ self.list, self.name, self.address, self.accepted,
35049+ )
35050+ }
35051+ }
35052 diff --git a/mailpot/src/models/changesets.rs b/mailpot/src/models/changesets.rs
35053new file mode 100644
35054index 0000000..93ab14e
35055--- /dev/null
35056+++ b/mailpot/src/models/changesets.rs
35057 @@ -0,0 +1,120 @@
35058+ /*
35059+ * This file is part of mailpot
35060+ *
35061+ * Copyright 2020 - Manos Pitsidianakis
35062+ *
35063+ * This program is free software: you can redistribute it and/or modify
35064+ * it under the terms of the GNU Affero General Public License as
35065+ * published by the Free Software Foundation, either version 3 of the
35066+ * License, or (at your option) any later version.
35067+ *
35068+ * This program is distributed in the hope that it will be useful,
35069+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
35070+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
35071+ * GNU Affero General Public License for more details.
35072+ *
35073+ * You should have received a copy of the GNU Affero General Public License
35074+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
35075+ */
35076+
35077+ //! Changeset structs: update specific struct fields.
35078+
35079+ macro_rules! impl_display {
35080+ ($t:ty) => {
35081+ impl std::fmt::Display for $t {
35082+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
35083+ write!(fmt, "{:?}", self)
35084+ }
35085+ }
35086+ };
35087+ }
35088+
35089+ /// Changeset struct for [`Mailinglist`](super::MailingList).
35090+ #[derive(Default, Debug, Clone, Deserialize, Serialize)]
35091+ pub struct MailingListChangeset {
35092+ /// Database primary key.
35093+ pub pk: i64,
35094+ /// Optional new value.
35095+ pub name: Option<String>,
35096+ /// Optional new value.
35097+ pub id: Option<String>,
35098+ /// Optional new value.
35099+ pub address: Option<String>,
35100+ /// Optional new value.
35101+ pub description: Option<Option<String>>,
35102+ /// Optional new value.
35103+ pub archive_url: Option<Option<String>>,
35104+ /// Optional new value.
35105+ pub owner_local_part: Option<Option<String>>,
35106+ /// Optional new value.
35107+ pub request_local_part: Option<Option<String>>,
35108+ /// Optional new value.
35109+ pub verify: Option<bool>,
35110+ /// Optional new value.
35111+ pub hidden: Option<bool>,
35112+ /// Optional new value.
35113+ pub enabled: Option<bool>,
35114+ }
35115+
35116+ impl_display!(MailingListChangeset);
35117+
35118+ /// Changeset struct for [`ListSubscription`](super::ListSubscription).
35119+ #[derive(Default, Debug, Clone, Deserialize, Serialize)]
35120+ pub struct ListSubscriptionChangeset {
35121+ /// Mailing list foreign key (See [`MailingList`](super::MailingList)).
35122+ pub list: i64,
35123+ /// Subscription e-mail address.
35124+ pub address: String,
35125+ /// Optional new value.
35126+ pub account: Option<Option<i64>>,
35127+ /// Optional new value.
35128+ pub name: Option<Option<String>>,
35129+ /// Optional new value.
35130+ pub digest: Option<bool>,
35131+ /// Optional new value.
35132+ pub enabled: Option<bool>,
35133+ /// Optional new value.
35134+ pub verified: Option<bool>,
35135+ /// Optional new value.
35136+ pub hide_address: Option<bool>,
35137+ /// Optional new value.
35138+ pub receive_duplicates: Option<bool>,
35139+ /// Optional new value.
35140+ pub receive_own_posts: Option<bool>,
35141+ /// Optional new value.
35142+ pub receive_confirmation: Option<bool>,
35143+ }
35144+
35145+ impl_display!(ListSubscriptionChangeset);
35146+
35147+ /// Changeset struct for [`ListOwner`](super::ListOwner).
35148+ #[derive(Default, Debug, Clone, Deserialize, Serialize)]
35149+ pub struct ListOwnerChangeset {
35150+ /// Database primary key.
35151+ pub pk: i64,
35152+ /// Mailing list foreign key (See [`MailingList`](super::MailingList)).
35153+ pub list: i64,
35154+ /// Optional new value.
35155+ pub address: Option<String>,
35156+ /// Optional new value.
35157+ pub name: Option<Option<String>>,
35158+ }
35159+
35160+ impl_display!(ListOwnerChangeset);
35161+
35162+ /// Changeset struct for [`Account`](super::Account).
35163+ #[derive(Default, Debug, Clone, Deserialize, Serialize)]
35164+ pub struct AccountChangeset {
35165+ /// Account e-mail address.
35166+ pub address: String,
35167+ /// Optional new value.
35168+ pub name: Option<Option<String>>,
35169+ /// Optional new value.
35170+ pub public_key: Option<Option<String>>,
35171+ /// Optional new value.
35172+ pub password: Option<String>,
35173+ /// Optional new value.
35174+ pub enabled: Option<Option<bool>>,
35175+ }
35176+
35177+ impl_display!(AccountChangeset);
35178 diff --git a/mailpot/src/policies.rs b/mailpot/src/policies.rs
35179new file mode 100644
35180index 0000000..1632653
35181--- /dev/null
35182+++ b/mailpot/src/policies.rs
35183 @@ -0,0 +1,404 @@
35184+ /*
35185+ * This file is part of mailpot
35186+ *
35187+ * Copyright 2020 - Manos Pitsidianakis
35188+ *
35189+ * This program is free software: you can redistribute it and/or modify
35190+ * it under the terms of the GNU Affero General Public License as
35191+ * published by the Free Software Foundation, either version 3 of the
35192+ * License, or (at your option) any later version.
35193+ *
35194+ * This program is distributed in the hope that it will be useful,
35195+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
35196+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
35197+ * GNU Affero General Public License for more details.
35198+ *
35199+ * You should have received a copy of the GNU Affero General Public License
35200+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
35201+ */
35202+
35203+ //! How each list handles new posts and new subscriptions.
35204+
35205+ mod post_policy {
35206+ use log::trace;
35207+ use rusqlite::OptionalExtension;
35208+
35209+ use crate::{
35210+ errors::{ErrorKind::*, *},
35211+ models::{DbVal, PostPolicy},
35212+ Connection,
35213+ };
35214+
35215+ impl Connection {
35216+ /// Fetch the post policy of a mailing list.
35217+ pub fn list_post_policy(&self, pk: i64) -> Result<Option<DbVal<PostPolicy>>> {
35218+ let mut stmt = self
35219+ .connection
35220+ .prepare("SELECT * FROM post_policy WHERE list = ?;")?;
35221+ let ret = stmt
35222+ .query_row([&pk], |row| {
35223+ let pk = row.get("pk")?;
35224+ Ok(DbVal(
35225+ PostPolicy {
35226+ pk,
35227+ list: row.get("list")?,
35228+ announce_only: row.get("announce_only")?,
35229+ subscription_only: row.get("subscription_only")?,
35230+ approval_needed: row.get("approval_needed")?,
35231+ open: row.get("open")?,
35232+ custom: row.get("custom")?,
35233+ },
35234+ pk,
35235+ ))
35236+ })
35237+ .optional()?;
35238+
35239+ Ok(ret)
35240+ }
35241+
35242+ /// Remove an existing list policy.
35243+ ///
35244+ /// # Examples
35245+ ///
35246+ /// ```
35247+ /// # use mailpot::{models::*, Configuration, Connection, SendMail};
35248+ /// # use tempfile::TempDir;
35249+ /// #
35250+ /// # let tmp_dir = TempDir::new().unwrap();
35251+ /// # let db_path = tmp_dir.path().join("mpot.db");
35252+ /// # let config = Configuration {
35253+ /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
35254+ /// # db_path: db_path.clone(),
35255+ /// # data_path: tmp_dir.path().to_path_buf(),
35256+ /// # administrators: vec![],
35257+ /// # };
35258+ /// #
35259+ /// # fn do_test(config: Configuration) {
35260+ /// let db = Connection::open_or_create_db(config).unwrap().trusted();
35261+ /// # assert!(db.list_post_policy(1).unwrap().is_none());
35262+ /// let list = db
35263+ /// .create_list(MailingList {
35264+ /// pk: 0,
35265+ /// name: "foobar chat".into(),
35266+ /// id: "foo-chat".into(),
35267+ /// address: "foo-chat@example.com".into(),
35268+ /// description: None,
35269+ /// topics: vec![],
35270+ /// archive_url: None,
35271+ /// })
35272+ /// .unwrap();
35273+ ///
35274+ /// # assert!(db.list_post_policy(list.pk()).unwrap().is_none());
35275+ /// let pol = db
35276+ /// .set_list_post_policy(PostPolicy {
35277+ /// pk: -1,
35278+ /// list: list.pk(),
35279+ /// announce_only: false,
35280+ /// subscription_only: true,
35281+ /// approval_needed: false,
35282+ /// open: false,
35283+ /// custom: false,
35284+ /// })
35285+ /// .unwrap();
35286+ /// # assert_eq!(db.list_post_policy(list.pk()).unwrap().as_ref(), Some(&pol));
35287+ /// db.remove_list_post_policy(list.pk(), pol.pk()).unwrap();
35288+ /// # assert!(db.list_post_policy(list.pk()).unwrap().is_none());
35289+ /// # }
35290+ /// # do_test(config);
35291+ /// ```
35292+ ///
35293+ /// ```should_panic
35294+ /// # use mailpot::{models::*, Configuration, Connection, SendMail};
35295+ /// # use tempfile::TempDir;
35296+ /// #
35297+ /// # let tmp_dir = TempDir::new().unwrap();
35298+ /// # let db_path = tmp_dir.path().join("mpot.db");
35299+ /// # let config = Configuration {
35300+ /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
35301+ /// # db_path: db_path.clone(),
35302+ /// # data_path: tmp_dir.path().to_path_buf(),
35303+ /// # administrators: vec![],
35304+ /// # };
35305+ /// #
35306+ /// # fn do_test(config: Configuration) {
35307+ /// let db = Connection::open_or_create_db(config).unwrap().trusted();
35308+ /// db.remove_list_post_policy(1, 1).unwrap();
35309+ /// # }
35310+ /// # do_test(config);
35311+ /// ```
35312+ pub fn remove_list_post_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
35313+ let mut stmt = self
35314+ .connection
35315+ .prepare("DELETE FROM post_policy WHERE pk = ? AND list = ? RETURNING *;")?;
35316+ stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
35317+ .map_err(|err| {
35318+ if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
35319+ Error::from(err).chain_err(|| NotFound("list or list policy not found!"))
35320+ } else {
35321+ err.into()
35322+ }
35323+ })?;
35324+
35325+ trace!("remove_list_post_policy {} {}.", list_pk, policy_pk);
35326+ Ok(())
35327+ }
35328+
35329+ /// Set the unique post policy for a list.
35330+ pub fn set_list_post_policy(&self, policy: PostPolicy) -> Result<DbVal<PostPolicy>> {
35331+ if !(policy.announce_only
35332+ || policy.subscription_only
35333+ || policy.approval_needed
35334+ || policy.open
35335+ || policy.custom)
35336+ {
35337+ return Err(Error::new_external(
35338+ "Cannot add empty policy. Having no policies is probably what you want to do.",
35339+ ));
35340+ }
35341+ let list_pk = policy.list;
35342+
35343+ let mut stmt = self.connection.prepare(
35344+ "INSERT OR REPLACE INTO post_policy(list, announce_only, subscription_only, \
35345+ approval_needed, open, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;",
35346+ )?;
35347+ let ret = stmt
35348+ .query_row(
35349+ rusqlite::params![
35350+ &list_pk,
35351+ &policy.announce_only,
35352+ &policy.subscription_only,
35353+ &policy.approval_needed,
35354+ &policy.open,
35355+ &policy.custom,
35356+ ],
35357+ |row| {
35358+ let pk = row.get("pk")?;
35359+ Ok(DbVal(
35360+ PostPolicy {
35361+ pk,
35362+ list: row.get("list")?,
35363+ announce_only: row.get("announce_only")?,
35364+ subscription_only: row.get("subscription_only")?,
35365+ approval_needed: row.get("approval_needed")?,
35366+ open: row.get("open")?,
35367+ custom: row.get("custom")?,
35368+ },
35369+ pk,
35370+ ))
35371+ },
35372+ )
35373+ .map_err(|err| {
35374+ if matches!(
35375+ err,
35376+ rusqlite::Error::SqliteFailure(
35377+ rusqlite::ffi::Error {
35378+ code: rusqlite::ffi::ErrorCode::ConstraintViolation,
35379+ extended_code: 787
35380+ },
35381+ _
35382+ )
35383+ ) {
35384+ Error::from(err)
35385+ .chain_err(|| NotFound("Could not find a list with this pk."))
35386+ } else {
35387+ err.into()
35388+ }
35389+ })?;
35390+
35391+ trace!("set_list_post_policy {:?}.", &ret);
35392+ Ok(ret)
35393+ }
35394+ }
35395+ }
35396+
35397+ mod subscription_policy {
35398+ use log::trace;
35399+ use rusqlite::OptionalExtension;
35400+
35401+ use crate::{
35402+ errors::{ErrorKind::*, *},
35403+ models::{DbVal, SubscriptionPolicy},
35404+ Connection,
35405+ };
35406+
35407+ impl Connection {
35408+ /// Fetch the subscription policy of a mailing list.
35409+ pub fn list_subscription_policy(
35410+ &self,
35411+ pk: i64,
35412+ ) -> Result<Option<DbVal<SubscriptionPolicy>>> {
35413+ let mut stmt = self
35414+ .connection
35415+ .prepare("SELECT * FROM subscription_policy WHERE list = ?;")?;
35416+ let ret = stmt
35417+ .query_row([&pk], |row| {
35418+ let pk = row.get("pk")?;
35419+ Ok(DbVal(
35420+ SubscriptionPolicy {
35421+ pk,
35422+ list: row.get("list")?,
35423+ send_confirmation: row.get("send_confirmation")?,
35424+ open: row.get("open")?,
35425+ manual: row.get("manual")?,
35426+ request: row.get("request")?,
35427+ custom: row.get("custom")?,
35428+ },
35429+ pk,
35430+ ))
35431+ })
35432+ .optional()?;
35433+
35434+ Ok(ret)
35435+ }
35436+
35437+ /// Remove an existing subscription policy.
35438+ ///
35439+ /// # Examples
35440+ ///
35441+ /// ```
35442+ /// # use mailpot::{models::*, Configuration, Connection, SendMail};
35443+ /// # use tempfile::TempDir;
35444+ /// #
35445+ /// # let tmp_dir = TempDir::new().unwrap();
35446+ /// # let db_path = tmp_dir.path().join("mpot.db");
35447+ /// # let config = Configuration {
35448+ /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
35449+ /// # db_path: db_path.clone(),
35450+ /// # data_path: tmp_dir.path().to_path_buf(),
35451+ /// # administrators: vec![],
35452+ /// # };
35453+ /// #
35454+ /// # fn do_test(config: Configuration) {
35455+ /// let db = Connection::open_or_create_db(config).unwrap().trusted();
35456+ /// let list = db
35457+ /// .create_list(MailingList {
35458+ /// pk: 0,
35459+ /// name: "foobar chat".into(),
35460+ /// id: "foo-chat".into(),
35461+ /// address: "foo-chat@example.com".into(),
35462+ /// description: None,
35463+ /// topics: vec![],
35464+ /// archive_url: None,
35465+ /// })
35466+ /// .unwrap();
35467+ /// # assert!(db.list_subscription_policy(list.pk()).unwrap().is_none());
35468+ /// let pol = db
35469+ /// .set_list_subscription_policy(SubscriptionPolicy {
35470+ /// pk: -1,
35471+ /// list: list.pk(),
35472+ /// send_confirmation: false,
35473+ /// open: true,
35474+ /// manual: false,
35475+ /// request: false,
35476+ /// custom: false,
35477+ /// })
35478+ /// .unwrap();
35479+ /// # assert_eq!(db.list_subscription_policy(list.pk()).unwrap().as_ref(), Some(&pol));
35480+ /// db.remove_list_subscription_policy(list.pk(), pol.pk())
35481+ /// .unwrap();
35482+ /// # assert!(db.list_subscription_policy(list.pk()).unwrap().is_none());
35483+ /// # }
35484+ /// # do_test(config);
35485+ /// ```
35486+ ///
35487+ /// ```should_panic
35488+ /// # use mailpot::{models::*, Configuration, Connection, SendMail};
35489+ /// # use tempfile::TempDir;
35490+ /// #
35491+ /// # let tmp_dir = TempDir::new().unwrap();
35492+ /// # let db_path = tmp_dir.path().join("mpot.db");
35493+ /// # let config = Configuration {
35494+ /// # send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
35495+ /// # db_path: db_path.clone(),
35496+ /// # data_path: tmp_dir.path().to_path_buf(),
35497+ /// # administrators: vec![],
35498+ /// # };
35499+ /// #
35500+ /// # fn do_test(config: Configuration) {
35501+ /// let db = Connection::open_or_create_db(config).unwrap().trusted();
35502+ /// db.remove_list_post_policy(1, 1).unwrap();
35503+ /// # }
35504+ /// # do_test(config);
35505+ /// ```
35506+ pub fn remove_list_subscription_policy(&self, list_pk: i64, policy_pk: i64) -> Result<()> {
35507+ let mut stmt = self.connection.prepare(
35508+ "DELETE FROM subscription_policy WHERE pk = ? AND list = ? RETURNING *;",
35509+ )?;
35510+ stmt.query_row(rusqlite::params![&policy_pk, &list_pk,], |_| Ok(()))
35511+ .map_err(|err| {
35512+ if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
35513+ Error::from(err).chain_err(|| NotFound("list or list policy not found!"))
35514+ } else {
35515+ err.into()
35516+ }
35517+ })?;
35518+
35519+ trace!("remove_list_subscription_policy {} {}.", list_pk, policy_pk);
35520+ Ok(())
35521+ }
35522+
35523+ /// Set the unique post policy for a list.
35524+ pub fn set_list_subscription_policy(
35525+ &self,
35526+ policy: SubscriptionPolicy,
35527+ ) -> Result<DbVal<SubscriptionPolicy>> {
35528+ if !(policy.open || policy.manual || policy.request || policy.custom) {
35529+ return Err(Error::new_external(
35530+ "Cannot add empty policy. Having no policy is probably what you want to do.",
35531+ ));
35532+ }
35533+ let list_pk = policy.list;
35534+
35535+ let mut stmt = self.connection.prepare(
35536+ "INSERT OR REPLACE INTO subscription_policy(list, send_confirmation, open, \
35537+ manual, request, custom) VALUES (?, ?, ?, ?, ?, ?) RETURNING *;",
35538+ )?;
35539+ let ret = stmt
35540+ .query_row(
35541+ rusqlite::params![
35542+ &list_pk,
35543+ &policy.send_confirmation,
35544+ &policy.open,
35545+ &policy.manual,
35546+ &policy.request,
35547+ &policy.custom,
35548+ ],
35549+ |row| {
35550+ let pk = row.get("pk")?;
35551+ Ok(DbVal(
35552+ SubscriptionPolicy {
35553+ pk,
35554+ list: row.get("list")?,
35555+ send_confirmation: row.get("send_confirmation")?,
35556+ open: row.get("open")?,
35557+ manual: row.get("manual")?,
35558+ request: row.get("request")?,
35559+ custom: row.get("custom")?,
35560+ },
35561+ pk,
35562+ ))
35563+ },
35564+ )
35565+ .map_err(|err| {
35566+ if matches!(
35567+ err,
35568+ rusqlite::Error::SqliteFailure(
35569+ rusqlite::ffi::Error {
35570+ code: rusqlite::ffi::ErrorCode::ConstraintViolation,
35571+ extended_code: 787
35572+ },
35573+ _
35574+ )
35575+ ) {
35576+ Error::from(err)
35577+ .chain_err(|| NotFound("Could not find a list with this pk."))
35578+ } else {
35579+ err.into()
35580+ }
35581+ })?;
35582+
35583+ trace!("set_list_subscription_policy {:?}.", &ret);
35584+ Ok(ret)
35585+ }
35586+ }
35587+ }
35588 diff --git a/mailpot/src/postfix.rs b/mailpot/src/postfix.rs
35589new file mode 100644
35590index 0000000..519f803
35591--- /dev/null
35592+++ b/mailpot/src/postfix.rs
35593 @@ -0,0 +1,678 @@
35594+ /*
35595+ * This file is part of mailpot
35596+ *
35597+ * Copyright 2020 - Manos Pitsidianakis
35598+ *
35599+ * This program is free software: you can redistribute it and/or modify
35600+ * it under the terms of the GNU Affero General Public License as
35601+ * published by the Free Software Foundation, either version 3 of the
35602+ * License, or (at your option) any later version.
35603+ *
35604+ * This program is distributed in the hope that it will be useful,
35605+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
35606+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
35607+ * GNU Affero General Public License for more details.
35608+ *
35609+ * You should have received a copy of the GNU Affero General Public License
35610+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
35611+ */
35612+
35613+ //! Generate configuration for the postfix mail server.
35614+ //!
35615+ //! ## Transport maps (`transport_maps`)
35616+ //!
35617+ //! <http://www.postfix.org/postconf.5.html#transport_maps>
35618+ //!
35619+ //! ## Local recipient maps (`local_recipient_maps`)
35620+ //!
35621+ //! <http://www.postfix.org/postconf.5.html#local_recipient_maps>
35622+ //!
35623+ //! ## Relay domains (`relay_domains`)
35624+ //!
35625+ //! <http://www.postfix.org/postconf.5.html#relay_domains>
35626+
35627+ use std::{
35628+ borrow::Cow,
35629+ convert::TryInto,
35630+ fs::OpenOptions,
35631+ io::{BufWriter, Read, Seek, Write},
35632+ path::{Path, PathBuf},
35633+ };
35634+
35635+ use crate::{errors::*, Configuration, Connection, DbVal, MailingList, PostPolicy};
35636+
35637+ /*
35638+ transport_maps =
35639+ hash:/path-to-mailman/var/data/postfix_lmtp
35640+ local_recipient_maps =
35641+ hash:/path-to-mailman/var/data/postfix_lmtp
35642+ relay_domains =
35643+ hash:/path-to-mailman/var/data/postfix_domains
35644+ */
35645+
35646+ /// Settings for generating postfix configuration.
35647+ ///
35648+ /// See the struct methods for details.
35649+ #[derive(Debug, Clone, Deserialize, Serialize)]
35650+ pub struct PostfixConfiguration {
35651+ /// The UNIX username under which the mailpot process who processed incoming
35652+ /// mail is launched.
35653+ pub user: Cow<'static, str>,
35654+ /// The UNIX group under which the mailpot process who processed incoming
35655+ /// mail is launched.
35656+ pub group: Option<Cow<'static, str>>,
35657+ /// The absolute path of the `mailpot` binary.
35658+ pub binary_path: PathBuf,
35659+ /// The maximum number of `mailpot` processes to launch. Default is `1`.
35660+ #[serde(default)]
35661+ pub process_limit: Option<u64>,
35662+ /// The directory in which the map files are saved.
35663+ /// Default is `data_path` from [`Configuration`].
35664+ #[serde(default)]
35665+ pub map_output_path: Option<PathBuf>,
35666+ /// The name of the Postfix service name to use.
35667+ /// Default is `mailpot`.
35668+ ///
35669+ /// A Postfix service is a daemon managed by the postfix process.
35670+ /// Each entry in the `master.cf` configuration file defines a single
35671+ /// service.
35672+ ///
35673+ /// The `master.cf` file is documented in [`master(5)`](https://www.postfix.org/master.5.html):
35674+ /// <https://www.postfix.org/master.5.html>.
35675+ #[serde(default)]
35676+ pub transport_name: Option<Cow<'static, str>>,
35677+ }
35678+
35679+ impl Default for PostfixConfiguration {
35680+ fn default() -> Self {
35681+ Self {
35682+ user: "user".into(),
35683+ group: None,
35684+ binary_path: Path::new("/usr/bin/mailpot").to_path_buf(),
35685+ process_limit: None,
35686+ map_output_path: None,
35687+ transport_name: None,
35688+ }
35689+ }
35690+ }
35691+
35692+ impl PostfixConfiguration {
35693+ /// Generate service line entry for Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
35694+ pub fn generate_master_cf_entry(&self, config: &Configuration, config_path: &Path) -> String {
35695+ let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
35696+ format!(
35697+ "{transport_name} unix - n n - {process_limit} pipe
35698+ flags=RX user={username}{group_sep}{groupname} directory={{{data_dir}}} argv={{{binary_path}}} -c \
35699+ {{{config_path}}} post",
35700+ username = &self.user,
35701+ group_sep = if self.group.is_none() { "" } else { ":" },
35702+ groupname = self.group.as_deref().unwrap_or_default(),
35703+ process_limit = self.process_limit.unwrap_or(1),
35704+ binary_path = &self.binary_path.display(),
35705+ config_path = &config_path.display(),
35706+ data_dir = &config.data_path.display()
35707+ )
35708+ }
35709+
35710+ /// Generate `transport_maps` and `local_recipient_maps` for Postfix.
35711+ ///
35712+ /// The output must be saved in a plain text file.
35713+ /// To make Postfix be able to read them, the `postmap` application must be
35714+ /// executed with the path to the map file as its sole argument.
35715+ /// `postmap` is usually distributed along with the other Postfix binaries.
35716+ pub fn generate_maps(
35717+ &self,
35718+ lists: &[(DbVal<MailingList>, Option<DbVal<PostPolicy>>)],
35719+ ) -> String {
35720+ let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
35721+ let mut ret = String::new();
35722+ ret.push_str("# Automatically generated by mailpot.\n");
35723+ ret.push_str(
35724+ "# Upon its creation and every time it is modified, postmap(1) must be called for the \
35725+ changes to take effect:\n",
35726+ );
35727+ ret.push_str("# postmap /path/to/map_file\n\n");
35728+
35729+ // [ref:TODO]: add custom addresses if PostPolicy is custom
35730+ let calc_width = |list: &MailingList, policy: Option<&PostPolicy>| -> usize {
35731+ let addr = list.address.len();
35732+ match policy {
35733+ None => 0,
35734+ Some(PostPolicy { .. }) => addr + "+request".len(),
35735+ }
35736+ };
35737+
35738+ let Some(width): Option<usize> =
35739+ lists.iter().map(|(l, p)| calc_width(l, p.as_deref())).max()
35740+ else {
35741+ return ret;
35742+ };
35743+
35744+ for (list, policy) in lists {
35745+ macro_rules! push_addr {
35746+ ($addr:expr) => {{
35747+ let addr = &$addr;
35748+ ret.push_str(addr);
35749+ for _ in 0..(width - addr.len() + 5) {
35750+ ret.push(' ');
35751+ }
35752+ ret.push_str(transport_name);
35753+ ret.push_str(":\n");
35754+ }};
35755+ }
35756+
35757+ match policy.as_deref() {
35758+ None => log::debug!(
35759+ "Not generating postfix map entry for list {} because it has no post_policy \
35760+ set.",
35761+ list.id
35762+ ),
35763+ Some(PostPolicy { open: true, .. }) => {
35764+ push_addr!(list.address);
35765+ ret.push('\n');
35766+ }
35767+ Some(PostPolicy { .. }) => {
35768+ push_addr!(list.address);
35769+ push_addr!(list.subscription_mailto().address);
35770+ push_addr!(list.owner_mailto().address);
35771+ ret.push('\n');
35772+ }
35773+ }
35774+ }
35775+
35776+ // pop second of the last two newlines
35777+ ret.pop();
35778+
35779+ ret
35780+ }
35781+
35782+ /// Save service to Postfix's [`master.cf`](https://www.postfix.org/master.5.html) file.
35783+ ///
35784+ /// If you wish to do it manually, get the text output from
35785+ /// [`PostfixConfiguration::generate_master_cf_entry`] and manually append it to the [`master.cf`](https://www.postfix.org/master.5.html) file.
35786+ ///
35787+ /// If `master_cf_path` is `None`, the location of the file is assumed to be
35788+ /// `/etc/postfix/master.cf`.
35789+ pub fn save_master_cf_entry(
35790+ &self,
35791+ config: &Configuration,
35792+ config_path: &Path,
35793+ master_cf_path: Option<&Path>,
35794+ ) -> Result<()> {
35795+ let new_entry = self.generate_master_cf_entry(config, config_path);
35796+ let path = master_cf_path.unwrap_or_else(|| Path::new("/etc/postfix/master.cf"));
35797+
35798+ // Create backup file.
35799+ let path_bkp = path.with_extension("cf.bkp");
35800+ std::fs::copy(path, &path_bkp).context(format!(
35801+ "Could not create master.cf backup {}",
35802+ path_bkp.display()
35803+ ))?;
35804+ log::info!(
35805+ "Created backup of {} to {}.",
35806+ path.display(),
35807+ path_bkp.display()
35808+ );
35809+
35810+ let mut file = OpenOptions::new()
35811+ .read(true)
35812+ .write(true)
35813+ .create(false)
35814+ .open(path)
35815+ .context(format!("Could not open {}", path.display()))?;
35816+
35817+ let mut previous_content = String::new();
35818+
35819+ file.rewind()
35820+ .context(format!("Could not access {}", path.display()))?;
35821+ file.read_to_string(&mut previous_content)
35822+ .context(format!("Could not access {}", path.display()))?;
35823+
35824+ let original_size = previous_content.len();
35825+
35826+ let lines = previous_content.lines().collect::<Vec<&str>>();
35827+ let transport_name = self.transport_name.as_deref().unwrap_or("mailpot");
35828+
35829+ if let Some(line) = lines.iter().find(|l| l.starts_with(transport_name)) {
35830+ let pos = previous_content.find(line).ok_or_else(|| {
35831+ Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
35832+ })?;
35833+ let end_needle = " argv=";
35834+ let end_pos = previous_content[pos..]
35835+ .find(end_needle)
35836+ .and_then(|pos2| {
35837+ previous_content[(pos + pos2 + end_needle.len())..]
35838+ .find('\n')
35839+ .map(|p| p + pos + pos2 + end_needle.len())
35840+ })
35841+ .ok_or_else(|| {
35842+ Error::from(ErrorKind::Bug("Unepected logical error.".to_string()))
35843+ })?;
35844+ previous_content.replace_range(pos..end_pos, &new_entry);
35845+ } else {
35846+ previous_content.push_str(&new_entry);
35847+ previous_content.push('\n');
35848+ }
35849+
35850+ file.rewind()?;
35851+ if previous_content.len() < original_size {
35852+ file.set_len(
35853+ previous_content
35854+ .len()
35855+ .try_into()
35856+ .expect("Could not convert usize file size to u64"),
35857+ )?;
35858+ }
35859+ let mut file = BufWriter::new(file);
35860+ file.write_all(previous_content.as_bytes())
35861+ .context(format!("Could not access {}", path.display()))?;
35862+ file.flush()
35863+ .context(format!("Could not access {}", path.display()))?;
35864+ log::debug!("Saved new master.cf to {}.", path.display(),);
35865+
35866+ Ok(())
35867+ }
35868+
35869+ /// Generate `transport_maps` and `local_recipient_maps` for Postfix.
35870+ ///
35871+ /// To succeed the user the command is running under must have write and
35872+ /// read access to `postfix_data_directory` and the `postmap` binary
35873+ /// must be discoverable in your `PATH` environment variable.
35874+ ///
35875+ /// `postmap` is usually distributed along with the other Postfix binaries.
35876+ pub fn save_maps(&self, config: &Configuration) -> Result<()> {
35877+ let db = Connection::open_db(config.clone())?;
35878+ let Some(postmap) = find_binary_in_path("postmap") else {
35879+ return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
35880+ "Could not find postmap binary in PATH.",
35881+ ))));
35882+ };
35883+ let lists = db.lists()?;
35884+ let lists_post_policies = lists
35885+ .into_iter()
35886+ .map(|l| {
35887+ let pk = l.pk;
35888+ Ok((l, db.list_post_policy(pk)?))
35889+ })
35890+ .collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
35891+ let content = self.generate_maps(&lists_post_policies);
35892+ let path = self
35893+ .map_output_path
35894+ .as_deref()
35895+ .unwrap_or(&config.data_path)
35896+ .join("mailpot_postfix_map");
35897+ let mut file = BufWriter::new(
35898+ OpenOptions::new()
35899+ .read(true)
35900+ .write(true)
35901+ .create(true)
35902+ .truncate(true)
35903+ .open(&path)
35904+ .context(format!("Could not open {}", path.display()))?,
35905+ );
35906+ file.write_all(content.as_bytes())
35907+ .context(format!("Could not write to {}", path.display()))?;
35908+ file.flush()
35909+ .context(format!("Could not write to {}", path.display()))?;
35910+
35911+ let output = std::process::Command::new("sh")
35912+ .arg("-c")
35913+ .arg(&format!("{} {}", postmap.display(), path.display()))
35914+ .output()
35915+ .with_context(|| {
35916+ format!(
35917+ "Could not execute `postmap` binary in path {}",
35918+ postmap.display()
35919+ )
35920+ })?;
35921+ if !output.status.success() {
35922+ use std::os::unix::process::ExitStatusExt;
35923+ if let Some(code) = output.status.code() {
35924+ return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
35925+ format!(
35926+ "{} exited with {}.\nstderr was:\n---{}---\nstdout was\n---{}---\n",
35927+ code,
35928+ postmap.display(),
35929+ String::from_utf8_lossy(&output.stderr),
35930+ String::from_utf8_lossy(&output.stdout)
35931+ ),
35932+ ))));
35933+ } else if let Some(signum) = output.status.signal() {
35934+ return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
35935+ format!(
35936+ "{} was killed with signal {}.\nstderr was:\n---{}---\nstdout \
35937+ was\n---{}---\n",
35938+ signum,
35939+ postmap.display(),
35940+ String::from_utf8_lossy(&output.stderr),
35941+ String::from_utf8_lossy(&output.stdout)
35942+ ),
35943+ ))));
35944+ } else {
35945+ return Err(Error::from(ErrorKind::External(anyhow::Error::msg(
35946+ format!(
35947+ "{} failed for unknown reason.\nstderr was:\n---{}---\nstdout \
35948+ was\n---{}---\n",
35949+ postmap.display(),
35950+ String::from_utf8_lossy(&output.stderr),
35951+ String::from_utf8_lossy(&output.stdout)
35952+ ),
35953+ ))));
35954+ }
35955+ }
35956+
35957+ Ok(())
35958+ }
35959+ }
35960+
35961+ fn find_binary_in_path(binary_name: &str) -> Option<PathBuf> {
35962+ std::env::var_os("PATH").and_then(|paths| {
35963+ std::env::split_paths(&paths).find_map(|dir| {
35964+ let full_path = dir.join(binary_name);
35965+ if full_path.is_file() {
35966+ Some(full_path)
35967+ } else {
35968+ None
35969+ }
35970+ })
35971+ })
35972+ }
35973+
35974+ #[test]
35975+ fn test_postfix_generation() -> Result<()> {
35976+ use tempfile::TempDir;
35977+
35978+ use crate::*;
35979+
35980+ mailpot_tests::init_stderr_logging();
35981+
35982+ fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
35983+ use melib::smtp::*;
35984+ SmtpServerConf {
35985+ hostname: "127.0.0.1".into(),
35986+ port: 1025,
35987+ envelope_from: "foo-chat@example.com".into(),
35988+ auth: SmtpAuth::None,
35989+ security: SmtpSecurity::None,
35990+ extensions: Default::default(),
35991+ }
35992+ }
35993+
35994+ let tmp_dir = TempDir::new()?;
35995+
35996+ let db_path = tmp_dir.path().join("mpot.db");
35997+ let config = Configuration {
35998+ send_mail: SendMail::Smtp(get_smtp_conf()),
35999+ db_path,
36000+ data_path: tmp_dir.path().to_path_buf(),
36001+ administrators: vec![],
36002+ };
36003+ let config_path = tmp_dir.path().join("conf.toml");
36004+ {
36005+ let mut conf = OpenOptions::new()
36006+ .write(true)
36007+ .create(true)
36008+ .open(&config_path)?;
36009+ conf.write_all(config.to_toml().as_bytes())?;
36010+ conf.flush()?;
36011+ }
36012+
36013+ let db = Connection::open_or_create_db(config)?.trusted();
36014+ assert!(db.lists()?.is_empty());
36015+
36016+ // Create three lists:
36017+ //
36018+ // - One without any policy, which should not show up in postfix maps.
36019+ // - One with subscriptions disabled, which would only add the list address in
36020+ // postfix maps.
36021+ // - One with subscriptions enabled, which should add all addresses (list,
36022+ // list+{un,}subscribe, etc).
36023+
36024+ let first = db.create_list(MailingList {
36025+ pk: 0,
36026+ name: "first".into(),
36027+ id: "first".into(),
36028+ address: "first@example.com".into(),
36029+ description: None,
36030+ topics: vec![],
36031+ archive_url: None,
36032+ })?;
36033+ assert_eq!(first.pk(), 1);
36034+ let second = db.create_list(MailingList {
36035+ pk: 0,
36036+ name: "second".into(),
36037+ id: "second".into(),
36038+ address: "second@example.com".into(),
36039+ description: None,
36040+ topics: vec![],
36041+ archive_url: None,
36042+ })?;
36043+ assert_eq!(second.pk(), 2);
36044+ let post_policy = db.set_list_post_policy(PostPolicy {
36045+ pk: 0,
36046+ list: second.pk(),
36047+ announce_only: false,
36048+ subscription_only: false,
36049+ approval_needed: false,
36050+ open: true,
36051+ custom: false,
36052+ })?;
36053+
36054+ assert_eq!(post_policy.pk(), 1);
36055+ let third = db.create_list(MailingList {
36056+ pk: 0,
36057+ name: "third".into(),
36058+ id: "third".into(),
36059+ address: "third@example.com".into(),
36060+ description: None,
36061+ topics: vec![],
36062+ archive_url: None,
36063+ })?;
36064+ assert_eq!(third.pk(), 3);
36065+ let post_policy = db.set_list_post_policy(PostPolicy {
36066+ pk: 0,
36067+ list: third.pk(),
36068+ announce_only: false,
36069+ subscription_only: false,
36070+ approval_needed: true,
36071+ open: false,
36072+ custom: false,
36073+ })?;
36074+
36075+ assert_eq!(post_policy.pk(), 2);
36076+
36077+ let mut postfix_conf = PostfixConfiguration::default();
36078+
36079+ let expected_mastercf_entry = format!(
36080+ "mailpot unix - n n - 1 pipe
36081+ flags=RX user={} directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
36082+ &postfix_conf.user,
36083+ tmp_dir.path().display(),
36084+ config_path.display()
36085+ );
36086+ assert_eq!(
36087+ expected_mastercf_entry.trim_end(),
36088+ postfix_conf.generate_master_cf_entry(db.conf(), &config_path)
36089+ );
36090+
36091+ let lists = db.lists()?;
36092+ let lists_post_policies = lists
36093+ .into_iter()
36094+ .map(|l| {
36095+ let pk = l.pk;
36096+ Ok((l, db.list_post_policy(pk)?))
36097+ })
36098+ .collect::<Result<Vec<(DbVal<MailingList>, Option<DbVal<PostPolicy>>)>>>()?;
36099+ let maps = postfix_conf.generate_maps(&lists_post_policies);
36100+
36101+ let expected = "second@example.com mailpot:
36102+
36103+ third@example.com mailpot:
36104+ third+request@example.com mailpot:
36105+ third+owner@example.com mailpot:
36106+ ";
36107+ assert!(
36108+ maps.ends_with(expected),
36109+ "maps has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
36110+ maps,
36111+ expected
36112+ );
36113+
36114+ let master_edit_value = r#"#
36115+ # Postfix master process configuration file. For details on the format
36116+ # of the file, see the master(5) manual page (command: "man 5 master" or
36117+ # on-line: http://www.postfix.org/master.5.html).
36118+ #
36119+ # Do not forget to execute "postfix reload" after editing this file.
36120+ #
36121+ # ==========================================================================
36122+ # service type private unpriv chroot wakeup maxproc command + args
36123+ # (yes) (yes) (no) (never) (100)
36124+ # ==========================================================================
36125+ smtp inet n - y - - smtpd
36126+ pickup unix n - y 60 1 pickup
36127+ cleanup unix n - y - 0 cleanup
36128+ qmgr unix n - n 300 1 qmgr
36129+ #qmgr unix n - n 300 1 oqmgr
36130+ tlsmgr unix - - y 1000? 1 tlsmgr
36131+ rewrite unix - - y - - trivial-rewrite
36132+ bounce unix - - y - 0 bounce
36133+ defer unix - - y - 0 bounce
36134+ trace unix - - y - 0 bounce
36135+ verify unix - - y - 1 verify
36136+ flush unix n - y 1000? 0 flush
36137+ proxymap unix - - n - - proxymap
36138+ proxywrite unix - - n - 1 proxymap
36139+ smtp unix - - y - - smtp
36140+ relay unix - - y - - smtp
36141+ -o syslog_name=postfix/$service_name
36142+ showq unix n - y - - showq
36143+ error unix - - y - - error
36144+ retry unix - - y - - error
36145+ discard unix - - y - - discard
36146+ local unix - n n - - local
36147+ virtual unix - n n - - virtual
36148+ lmtp unix - - y - - lmtp
36149+ anvil unix - - y - 1 anvil
36150+ scache unix - - y - 1 scache
36151+ postlog unix-dgram n - n - 1 postlogd
36152+ maildrop unix - n n - - pipe
36153+ flags=DRXhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
36154+ uucp unix - n n - - pipe
36155+ flags=Fqhu user=uucp argv=uux -r -n -z -a$sender - $nexthop!rmail ($recipient)
36156+ #
36157+ # Other external delivery methods.
36158+ #
36159+ ifmail unix - n n - - pipe
36160+ flags=F user=ftn argv=/usr/lib/ifmail/ifmail -r $nexthop ($recipient)
36161+ bsmtp unix - n n - - pipe
36162+ flags=Fq. user=bsmtp argv=/usr/lib/bsmtp/bsmtp -t$nexthop -f$sender $recipient
36163+ scalemail-backend unix - n n - 2 pipe
36164+ flags=R user=scalemail argv=/usr/lib/scalemail/bin/scalemail-store ${nexthop} ${user} ${extension}
36165+ mailman unix - n n - - pipe
36166+ flags=FRX user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py ${nexthop} ${user}
36167+ "#;
36168+
36169+ let path = tmp_dir.path().join("master.cf");
36170+ {
36171+ let mut mastercf = OpenOptions::new().write(true).create(true).open(&path)?;
36172+ mastercf.write_all(master_edit_value.as_bytes())?;
36173+ mastercf.flush()?;
36174+ }
36175+ postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
36176+ let mut first = String::new();
36177+ {
36178+ let mut mastercf = OpenOptions::new()
36179+ .write(false)
36180+ .read(true)
36181+ .create(false)
36182+ .open(&path)?;
36183+ mastercf.read_to_string(&mut first)?;
36184+ }
36185+ assert!(
36186+ first.ends_with(&expected_mastercf_entry),
36187+ "edited master.cf has unexpected contents: has\n{:?}\nand should have ended with\n{:?}",
36188+ first,
36189+ expected_mastercf_entry
36190+ );
36191+
36192+ // test that a smaller entry can be successfully replaced
36193+
36194+ postfix_conf.user = "nobody".into();
36195+ postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
36196+ let mut second = String::new();
36197+ {
36198+ let mut mastercf = OpenOptions::new()
36199+ .write(false)
36200+ .read(true)
36201+ .create(false)
36202+ .open(&path)?;
36203+ mastercf.read_to_string(&mut second)?;
36204+ }
36205+ let expected_mastercf_entry = format!(
36206+ "mailpot unix - n n - 1 pipe
36207+ flags=RX user=nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
36208+ tmp_dir.path().display(),
36209+ config_path.display()
36210+ );
36211+ assert!(
36212+ second.ends_with(&expected_mastercf_entry),
36213+ "doubly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
36214+ with\n{:?}",
36215+ second,
36216+ expected_mastercf_entry
36217+ );
36218+ // test that a larger entry can be successfully replaced
36219+ postfix_conf.user = "hackerman".into();
36220+ postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
36221+ let mut third = String::new();
36222+ {
36223+ let mut mastercf = OpenOptions::new()
36224+ .write(false)
36225+ .read(true)
36226+ .create(false)
36227+ .open(&path)?;
36228+ mastercf.read_to_string(&mut third)?;
36229+ }
36230+ let expected_mastercf_entry = format!(
36231+ "mailpot unix - n n - 1 pipe
36232+ flags=RX user=hackerman directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
36233+ tmp_dir.path().display(),
36234+ config_path.display(),
36235+ );
36236+ assert!(
36237+ third.ends_with(&expected_mastercf_entry),
36238+ "triply edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
36239+ with\n{:?}",
36240+ third,
36241+ expected_mastercf_entry
36242+ );
36243+
36244+ // test that if groupname is given it is rendered correctly.
36245+ postfix_conf.group = Some("nobody".into());
36246+ postfix_conf.save_master_cf_entry(db.conf(), &config_path, Some(&path))?;
36247+ let mut fourth = String::new();
36248+ {
36249+ let mut mastercf = OpenOptions::new()
36250+ .write(false)
36251+ .read(true)
36252+ .create(false)
36253+ .open(&path)?;
36254+ mastercf.read_to_string(&mut fourth)?;
36255+ }
36256+ let expected_mastercf_entry = format!(
36257+ "mailpot unix - n n - 1 pipe
36258+ flags=RX user=hackerman:nobody directory={{{}}} argv={{/usr/bin/mailpot}} -c {{{}}} post\n",
36259+ tmp_dir.path().display(),
36260+ config_path.display(),
36261+ );
36262+ assert!(
36263+ fourth.ends_with(&expected_mastercf_entry),
36264+ "fourthly edited master.cf has unexpected contents: has\n{:?}\nand should have ended \
36265+ with\n{:?}",
36266+ fourth,
36267+ expected_mastercf_entry
36268+ );
36269+
36270+ Ok(())
36271+ }
36272 diff --git a/mailpot/src/posts.rs b/mailpot/src/posts.rs
36273new file mode 100644
36274index 0000000..d3525dd
36275--- /dev/null
36276+++ b/mailpot/src/posts.rs
36277 @@ -0,0 +1,801 @@
36278+ /*
36279+ * This file is part of mailpot
36280+ *
36281+ * Copyright 2020 - Manos Pitsidianakis
36282+ *
36283+ * This program is free software: you can redistribute it and/or modify
36284+ * it under the terms of the GNU Affero General Public License as
36285+ * published by the Free Software Foundation, either version 3 of the
36286+ * License, or (at your option) any later version.
36287+ *
36288+ * This program is distributed in the hope that it will be useful,
36289+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
36290+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
36291+ * GNU Affero General Public License for more details.
36292+ *
36293+ * You should have received a copy of the GNU Affero General Public License
36294+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
36295+ */
36296+
36297+ //! Processing new posts.
36298+
36299+ use std::borrow::Cow;
36300+
36301+ use log::{info, trace};
36302+ use melib::Envelope;
36303+ use rusqlite::OptionalExtension;
36304+
36305+ use crate::{
36306+ errors::*,
36307+ mail::{ListContext, ListRequest, PostAction, PostEntry},
36308+ models::{changesets::AccountChangeset, Account, DbVal, ListSubscription, MailingList, Post},
36309+ queue::{Queue, QueueEntry},
36310+ templates::Template,
36311+ Connection,
36312+ };
36313+
36314+ impl Connection {
36315+ /// Insert a mailing list post into the database.
36316+ pub fn insert_post(&self, list_pk: i64, message: &[u8], env: &Envelope) -> Result<i64> {
36317+ let from_ = env.from();
36318+ let address = if from_.is_empty() {
36319+ String::new()
36320+ } else {
36321+ from_[0].get_email()
36322+ };
36323+ let datetime: std::borrow::Cow<'_, str> = if !env.date.is_empty() {
36324+ env.date.as_str().into()
36325+ } else {
36326+ melib::utils::datetime::timestamp_to_string(
36327+ env.timestamp,
36328+ Some(melib::utils::datetime::formats::RFC822_DATE),
36329+ true,
36330+ )
36331+ .into()
36332+ };
36333+ let message_id = env.message_id_display();
36334+ let mut stmt = self.connection.prepare(
36335+ "INSERT OR REPLACE INTO post(list, address, message_id, message, datetime, timestamp) \
36336+ VALUES(?, ?, ?, ?, ?, ?) RETURNING pk;",
36337+ )?;
36338+ let pk = stmt.query_row(
36339+ rusqlite::params![
36340+ &list_pk,
36341+ &address,
36342+ &message_id,
36343+ &message,
36344+ &datetime,
36345+ &env.timestamp
36346+ ],
36347+ |row| {
36348+ let pk: i64 = row.get("pk")?;
36349+ Ok(pk)
36350+ },
36351+ )?;
36352+
36353+ trace!(
36354+ "insert_post list_pk {}, from {:?} message_id {:?} post_pk {}.",
36355+ list_pk,
36356+ address,
36357+ message_id,
36358+ pk
36359+ );
36360+ Ok(pk)
36361+ }
36362+
36363+ /// Process a new mailing list post.
36364+ ///
36365+ /// In case multiple processes can access the database at any time, use an
36366+ /// `EXCLUSIVE` transaction before calling this function.
36367+ /// See [`Connection::transaction`].
36368+ pub fn post(&self, env: &Envelope, raw: &[u8], _dry_run: bool) -> Result<()> {
36369+ let result = self.inner_post(env, raw, _dry_run);
36370+ if let Err(err) = result {
36371+ return match self.insert_to_queue(QueueEntry::new(
36372+ Queue::Error,
36373+ None,
36374+ Some(Cow::Borrowed(env)),
36375+ raw,
36376+ Some(err.to_string()),
36377+ )?) {
36378+ Ok(idx) => {
36379+ log::info!(
36380+ "Inserted mail from {:?} into error_queue at index {}",
36381+ env.from(),
36382+ idx
36383+ );
36384+ Err(err)
36385+ }
36386+ Err(err2) => {
36387+ log::error!(
36388+ "Could not insert mail from {:?} into error_queue: {err2}",
36389+ env.from(),
36390+ );
36391+
36392+ Err(err.chain_err(|| err2))
36393+ }
36394+ };
36395+ }
36396+ result
36397+ }
36398+
36399+ fn inner_post(&self, env: &Envelope, raw: &[u8], _dry_run: bool) -> Result<()> {
36400+ trace!("Received envelope to post: {:#?}", &env);
36401+ let tos = env.to().to_vec();
36402+ if tos.is_empty() {
36403+ return Err("Envelope To: field is empty!".into());
36404+ }
36405+ if env.from().is_empty() {
36406+ return Err("Envelope From: field is empty!".into());
36407+ }
36408+ let mut lists = self.lists()?;
36409+ let prev_list_len = lists.len();
36410+ for t in &tos {
36411+ if let Some((addr, subaddr)) = t.subaddress("+") {
36412+ lists.retain(|list| {
36413+ if !addr.contains_address(&list.address()) {
36414+ return true;
36415+ }
36416+ if let Err(err) = ListRequest::try_from((subaddr.as_str(), env))
36417+ .and_then(|req| self.request(list, req, env, raw))
36418+ {
36419+ info!("Processing request returned error: {}", err);
36420+ }
36421+ false
36422+ });
36423+ if lists.len() != prev_list_len {
36424+ // Was request, handled above.
36425+ return Ok(());
36426+ }
36427+ }
36428+ }
36429+
36430+ lists.retain(|list| {
36431+ trace!(
36432+ "Is post related to list {}? {}",
36433+ &list,
36434+ tos.iter().any(|a| a.contains_address(&list.address()))
36435+ );
36436+
36437+ tos.iter().any(|a| a.contains_address(&list.address()))
36438+ });
36439+ if lists.is_empty() {
36440+ return Err(format!(
36441+ "No relevant mailing list found for these addresses: {:?}",
36442+ tos
36443+ )
36444+ .into());
36445+ }
36446+
36447+ trace!("Configuration is {:#?}", &self.conf);
36448+ for mut list in lists {
36449+ trace!("Examining list {}", list.display_name());
36450+ let filters = self.list_filters(&list);
36451+ let subscriptions = self.list_subscriptions(list.pk)?;
36452+ let owners = self.list_owners(list.pk)?;
36453+ trace!("List subscriptions {:#?}", &subscriptions);
36454+ let mut list_ctx = ListContext {
36455+ post_policy: self.list_post_policy(list.pk)?,
36456+ subscription_policy: self.list_subscription_policy(list.pk)?,
36457+ list_owners: &owners,
36458+ subscriptions: &subscriptions,
36459+ scheduled_jobs: vec![],
36460+ filter_settings: self.get_settings(list.pk)?,
36461+ list: &mut list,
36462+ };
36463+ let mut post = PostEntry {
36464+ message_id: env.message_id().clone(),
36465+ from: env.from()[0].clone(),
36466+ bytes: raw.to_vec(),
36467+ to: env.to().to_vec(),
36468+ action: PostAction::Hold,
36469+ };
36470+ let result = filters
36471+ .into_iter()
36472+ .try_fold((&mut post, &mut list_ctx), |(p, c), f| f.feed(p, c));
36473+ trace!("result {:#?}", result);
36474+
36475+ let PostEntry { bytes, action, .. } = post;
36476+ trace!("Action is {:#?}", action);
36477+ let post_env = melib::Envelope::from_bytes(&bytes, None)?;
36478+ match action {
36479+ PostAction::Accept => {
36480+ let _post_pk = self.insert_post(list_ctx.list.pk, &bytes, &post_env)?;
36481+ trace!("post_pk is {:#?}", _post_pk);
36482+ for job in list_ctx.scheduled_jobs.iter() {
36483+ trace!("job is {:#?}", &job);
36484+ if let crate::mail::MailJob::Send { recipients } = job {
36485+ trace!("recipients: {:?}", &recipients);
36486+ if recipients.is_empty() {
36487+ trace!("list has no recipients");
36488+ }
36489+ for recipient in recipients {
36490+ let mut env = post_env.clone();
36491+ env.set_to(melib::smallvec::smallvec![recipient.clone()]);
36492+ self.insert_to_queue(QueueEntry::new(
36493+ Queue::Out,
36494+ Some(list.pk),
36495+ Some(Cow::Owned(env)),
36496+ &bytes,
36497+ None,
36498+ )?)?;
36499+ }
36500+ }
36501+ }
36502+ }
36503+ PostAction::Reject { reason } => {
36504+ log::info!("PostAction::Reject {{ reason: {} }}", reason);
36505+ for f in env.from() {
36506+ /* send error notice to e-mail sender */
36507+ self.send_reply_with_list_template(
36508+ TemplateRenderContext {
36509+ template: Template::GENERIC_FAILURE,
36510+ default_fn: Some(Template::default_generic_failure),
36511+ list: &list,
36512+ context: minijinja::context! {
36513+ list => &list,
36514+ subject => format!("Your post to {} was rejected.", list.id),
36515+ details => &reason,
36516+ },
36517+ queue: Queue::Out,
36518+ comment: format!("PostAction::Reject {{ reason: {} }}", reason)
36519+ .into(),
36520+ },
36521+ std::iter::once(Cow::Borrowed(f)),
36522+ )?;
36523+ }
36524+ /* error handled by notifying submitter */
36525+ return Ok(());
36526+ }
36527+ PostAction::Defer { reason } => {
36528+ trace!("PostAction::Defer {{ reason: {} }}", reason);
36529+ for f in env.from() {
36530+ /* send error notice to e-mail sender */
36531+ self.send_reply_with_list_template(
36532+ TemplateRenderContext {
36533+ template: Template::GENERIC_FAILURE,
36534+ default_fn: Some(Template::default_generic_failure),
36535+ list: &list,
36536+ context: minijinja::context! {
36537+ list => &list,
36538+ subject => format!("Your post to {} was deferred.", list.id),
36539+ details => &reason,
36540+ },
36541+ queue: Queue::Out,
36542+ comment: format!("PostAction::Defer {{ reason: {} }}", reason)
36543+ .into(),
36544+ },
36545+ std::iter::once(Cow::Borrowed(f)),
36546+ )?;
36547+ }
36548+ self.insert_to_queue(QueueEntry::new(
36549+ Queue::Deferred,
36550+ Some(list.pk),
36551+ Some(Cow::Borrowed(&post_env)),
36552+ &bytes,
36553+ Some(format!("PostAction::Defer {{ reason: {} }}", reason)),
36554+ )?)?;
36555+ return Ok(());
36556+ }
36557+ PostAction::Hold => {
36558+ trace!("PostAction::Hold");
36559+ self.insert_to_queue(QueueEntry::new(
36560+ Queue::Hold,
36561+ Some(list.pk),
36562+ Some(Cow::Borrowed(&post_env)),
36563+ &bytes,
36564+ Some("PostAction::Hold".to_string()),
36565+ )?)?;
36566+ return Ok(());
36567+ }
36568+ }
36569+ }
36570+
36571+ Ok(())
36572+ }
36573+
36574+ /// Process a new mailing list request.
36575+ pub fn request(
36576+ &self,
36577+ list: &DbVal<MailingList>,
36578+ request: ListRequest,
36579+ env: &Envelope,
36580+ raw: &[u8],
36581+ ) -> Result<()> {
36582+ match request {
36583+ ListRequest::Help => {
36584+ trace!(
36585+ "help action for addresses {:?} in list {}",
36586+ env.from(),
36587+ list
36588+ );
36589+ let subscription_policy = self.list_subscription_policy(list.pk)?;
36590+ let post_policy = self.list_post_policy(list.pk)?;
36591+ let subject = format!("Help for {}", list.name);
36592+ let details = list
36593+ .generate_help_email(post_policy.as_deref(), subscription_policy.as_deref());
36594+ for f in env.from() {
36595+ self.send_reply_with_list_template(
36596+ TemplateRenderContext {
36597+ template: Template::GENERIC_HELP,
36598+ default_fn: Some(Template::default_generic_help),
36599+ list,
36600+ context: minijinja::context! {
36601+ list => &list,
36602+ subject => &subject,
36603+ details => &details,
36604+ },
36605+ queue: Queue::Out,
36606+ comment: "Help request".into(),
36607+ },
36608+ std::iter::once(Cow::Borrowed(f)),
36609+ )?;
36610+ }
36611+ }
36612+ ListRequest::Subscribe => {
36613+ trace!(
36614+ "subscribe action for addresses {:?} in list {}",
36615+ env.from(),
36616+ list
36617+ );
36618+ let subscription_policy = self.list_subscription_policy(list.pk)?;
36619+ let approval_needed = subscription_policy
36620+ .as_ref()
36621+ .map(|p| !p.open)
36622+ .unwrap_or(false);
36623+ for f in env.from() {
36624+ let email_from = f.get_email();
36625+ if self
36626+ .list_subscription_by_address(list.pk, &email_from)
36627+ .is_ok()
36628+ {
36629+ /* send error notice to e-mail sender */
36630+ self.send_reply_with_list_template(
36631+ TemplateRenderContext {
36632+ template: Template::GENERIC_FAILURE,
36633+ default_fn: Some(Template::default_generic_failure),
36634+ list,
36635+ context: minijinja::context! {
36636+ list => &list,
36637+ subject => format!("You are already subscribed to {}.", list.id),
36638+ details => "No action has been taken since you are already subscribed to the list.",
36639+ },
36640+ queue: Queue::Out,
36641+ comment: format!("Address {} is already subscribed to list {}", f, list.id).into(),
36642+ },
36643+ std::iter::once(Cow::Borrowed(f)),
36644+ )?;
36645+ continue;
36646+ }
36647+
36648+ let subscription = ListSubscription {
36649+ pk: 0,
36650+ list: list.pk,
36651+ address: f.get_email(),
36652+ account: None,
36653+ name: f.get_display_name(),
36654+ digest: false,
36655+ hide_address: false,
36656+ receive_duplicates: true,
36657+ receive_own_posts: false,
36658+ receive_confirmation: true,
36659+ enabled: !approval_needed,
36660+ verified: true,
36661+ };
36662+ if approval_needed {
36663+ match self.add_candidate_subscription(list.pk, subscription) {
36664+ Ok(v) => {
36665+ let list_owners = self.list_owners(list.pk)?;
36666+ self.send_reply_with_list_template(
36667+ TemplateRenderContext {
36668+ template: Template::SUBSCRIPTION_REQUEST_NOTICE_OWNER,
36669+ default_fn: Some(
36670+ Template::default_subscription_request_owner,
36671+ ),
36672+ list,
36673+ context: minijinja::context! {
36674+ list => &list,
36675+ candidate => &v,
36676+ },
36677+ queue: Queue::Out,
36678+ comment: Template::SUBSCRIPTION_REQUEST_NOTICE_OWNER.into(),
36679+ },
36680+ list_owners.iter().map(|owner| Cow::Owned(owner.address())),
36681+ )?;
36682+ }
36683+ Err(err) => {
36684+ log::error!(
36685+ "Could not create candidate subscription for {f:?}: {err}"
36686+ );
36687+ /* send error notice to e-mail sender */
36688+ self.send_reply_with_list_template(
36689+ TemplateRenderContext {
36690+ template: Template::GENERIC_FAILURE,
36691+ default_fn: Some(Template::default_generic_failure),
36692+ list,
36693+ context: minijinja::context! {
36694+ list => &list,
36695+ },
36696+ queue: Queue::Out,
36697+ comment: format!(
36698+ "Could not create candidate subscription for {f:?}: \
36699+ {err}"
36700+ )
36701+ .into(),
36702+ },
36703+ std::iter::once(Cow::Borrowed(f)),
36704+ )?;
36705+
36706+ /* send error details to list owners */
36707+
36708+ let list_owners = self.list_owners(list.pk)?;
36709+ self.send_reply_with_list_template(
36710+ TemplateRenderContext {
36711+ template: Template::ADMIN_NOTICE,
36712+ default_fn: Some(Template::default_admin_notice),
36713+ list,
36714+ context: minijinja::context! {
36715+ list => &list,
36716+ details => err.to_string(),
36717+ },
36718+ queue: Queue::Out,
36719+ comment: format!(
36720+ "Could not create candidate subscription for {f:?}: \
36721+ {err}"
36722+ )
36723+ .into(),
36724+ },
36725+ list_owners.iter().map(|owner| Cow::Owned(owner.address())),
36726+ )?;
36727+ }
36728+ }
36729+ } else if let Err(err) = self.add_subscription(list.pk, subscription) {
36730+ log::error!("Could not create subscription for {f:?}: {err}");
36731+
36732+ /* send error notice to e-mail sender */
36733+
36734+ self.send_reply_with_list_template(
36735+ TemplateRenderContext {
36736+ template: Template::GENERIC_FAILURE,
36737+ default_fn: Some(Template::default_generic_failure),
36738+ list,
36739+ context: minijinja::context! {
36740+ list => &list,
36741+ },
36742+ queue: Queue::Out,
36743+ comment: format!("Could not create subscription for {f:?}: {err}")
36744+ .into(),
36745+ },
36746+ std::iter::once(Cow::Borrowed(f)),
36747+ )?;
36748+
36749+ /* send error details to list owners */
36750+
36751+ let list_owners = self.list_owners(list.pk)?;
36752+ self.send_reply_with_list_template(
36753+ TemplateRenderContext {
36754+ template: Template::ADMIN_NOTICE,
36755+ default_fn: Some(Template::default_admin_notice),
36756+ list,
36757+ context: minijinja::context! {
36758+ list => &list,
36759+ details => err.to_string(),
36760+ },
36761+ queue: Queue::Out,
36762+ comment: format!("Could not create subscription for {f:?}: {err}")
36763+ .into(),
36764+ },
36765+ list_owners.iter().map(|owner| Cow::Owned(owner.address())),
36766+ )?;
36767+ } else {
36768+ self.send_subscription_confirmation(list, f)?;
36769+ }
36770+ }
36771+ }
36772+ ListRequest::Unsubscribe => {
36773+ trace!(
36774+ "unsubscribe action for addresses {:?} in list {}",
36775+ env.from(),
36776+ list
36777+ );
36778+ for f in env.from() {
36779+ if let Err(err) = self.remove_subscription(list.pk, &f.get_email()) {
36780+ log::error!("Could not unsubscribe {f:?}: {err}");
36781+ /* send error notice to e-mail sender */
36782+
36783+ self.send_reply_with_list_template(
36784+ TemplateRenderContext {
36785+ template: Template::GENERIC_FAILURE,
36786+ default_fn: Some(Template::default_generic_failure),
36787+ list,
36788+ context: minijinja::context! {
36789+ list => &list,
36790+ },
36791+ queue: Queue::Out,
36792+ comment: format!("Could not unsubscribe {f:?}: {err}").into(),
36793+ },
36794+ std::iter::once(Cow::Borrowed(f)),
36795+ )?;
36796+
36797+ /* send error details to list owners */
36798+
36799+ let list_owners = self.list_owners(list.pk)?;
36800+ self.send_reply_with_list_template(
36801+ TemplateRenderContext {
36802+ template: Template::ADMIN_NOTICE,
36803+ default_fn: Some(Template::default_admin_notice),
36804+ list,
36805+ context: minijinja::context! {
36806+ list => &list,
36807+ details => err.to_string(),
36808+ },
36809+ queue: Queue::Out,
36810+ comment: format!("Could not unsubscribe {f:?}: {err}").into(),
36811+ },
36812+ list_owners.iter().map(|owner| Cow::Owned(owner.address())),
36813+ )?;
36814+ } else {
36815+ self.send_unsubscription_confirmation(list, f)?;
36816+ }
36817+ }
36818+ }
36819+ ListRequest::Other(ref req) if req == "owner" => {
36820+ trace!(
36821+ "list-owner mail action for addresses {:?} in list {}",
36822+ env.from(),
36823+ list
36824+ );
36825+ return Err("list-owner emails are not implemented yet.".into());
36826+ //FIXME: mail to list-owner
36827+ /*
36828+ for _owner in self.list_owners(list.pk)? {
36829+ self.insert_to_queue(
36830+ Queue::Out,
36831+ Some(list.pk),
36832+ None,
36833+ draft.finalise()?.as_bytes(),
36834+ "list-owner-forward".to_string(),
36835+ )?;
36836+ }
36837+ */
36838+ }
36839+ ListRequest::Other(ref req) if req.trim().eq_ignore_ascii_case("password") => {
36840+ trace!(
36841+ "list-request password set action for addresses {:?} in list {list}",
36842+ env.from(),
36843+ );
36844+ let body = env.body_bytes(raw);
36845+ let password = body.text();
36846+ // TODO: validate SSH public key with `ssh-keygen`.
36847+ for f in env.from() {
36848+ let email_from = f.get_email();
36849+ if let Ok(sub) = self.list_subscription_by_address(list.pk, &email_from) {
36850+ match self.account_by_address(&email_from)? {
36851+ Some(_acc) => {
36852+ let changeset = AccountChangeset {
36853+ address: email_from.clone(),
36854+ name: None,
36855+ public_key: None,
36856+ password: Some(password.clone()),
36857+ enabled: None,
36858+ };
36859+ self.update_account(changeset)?;
36860+ }
36861+ None => {
36862+ // Create new account.
36863+ self.add_account(Account {
36864+ pk: 0,
36865+ name: sub.name.clone(),
36866+ address: sub.address.clone(),
36867+ public_key: None,
36868+ password: password.clone(),
36869+ enabled: sub.enabled,
36870+ })?;
36871+ }
36872+ }
36873+ }
36874+ }
36875+ }
36876+ ListRequest::RetrieveMessages(ref message_ids) => {
36877+ trace!(
36878+ "retrieve messages {message_ids:?} action for addresses {:?} in list {list}",
36879+ env.from(),
36880+ );
36881+ return Err("message retrievals are not implemented yet.".into());
36882+ }
36883+ ListRequest::RetrieveArchive(ref from, ref to) => {
36884+ trace!(
36885+ "retrieve archive action from {from:?} to {to:?} for addresses {:?} in list \
36886+ {list}",
36887+ env.from(),
36888+ );
36889+ return Err("message retrievals are not implemented yet.".into());
36890+ }
36891+ ListRequest::ChangeSetting(ref setting, ref toggle) => {
36892+ trace!(
36893+ "change setting {setting}, request with value {toggle:?} for addresses {:?} \
36894+ in list {list}",
36895+ env.from(),
36896+ );
36897+ return Err("setting digest options via e-mail is not implemented yet.".into());
36898+ }
36899+ ListRequest::Other(ref req) => {
36900+ trace!(
36901+ "unknown request action {req} for addresses {:?} in list {list}",
36902+ env.from(),
36903+ );
36904+ return Err(format!("Unknown request {req}.").into());
36905+ }
36906+ }
36907+ Ok(())
36908+ }
36909+
36910+ /// Fetch all year and month values for which at least one post exists in
36911+ /// `yyyy-mm` format.
36912+ pub fn months(&self, list_pk: i64) -> Result<Vec<String>> {
36913+ let mut stmt = self.connection.prepare(
36914+ "SELECT DISTINCT strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') FROM post \
36915+ WHERE list = ?;",
36916+ )?;
36917+ let months_iter = stmt.query_map([list_pk], |row| {
36918+ let val: String = row.get(0)?;
36919+ Ok(val)
36920+ })?;
36921+
36922+ let mut ret = vec![];
36923+ for month in months_iter {
36924+ let month = month?;
36925+ ret.push(month);
36926+ }
36927+ Ok(ret)
36928+ }
36929+
36930+ /// Find a post by its `Message-ID` email header.
36931+ pub fn list_post_by_message_id(
36932+ &self,
36933+ list_pk: i64,
36934+ message_id: &str,
36935+ ) -> Result<Option<DbVal<Post>>> {
36936+ let mut stmt = self.connection.prepare(
36937+ "SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
36938+ FROM post WHERE list = ?1 AND (message_id = ?2 OR concat('<', ?2, '>') = message_id);",
36939+ )?;
36940+ let ret = stmt
36941+ .query_row(rusqlite::params![&list_pk, &message_id], |row| {
36942+ let pk = row.get("pk")?;
36943+ Ok(DbVal(
36944+ Post {
36945+ pk,
36946+ list: row.get("list")?,
36947+ envelope_from: row.get("envelope_from")?,
36948+ address: row.get("address")?,
36949+ message_id: row.get("message_id")?,
36950+ message: row.get("message")?,
36951+ timestamp: row.get("timestamp")?,
36952+ datetime: row.get("datetime")?,
36953+ month_year: row.get("month_year")?,
36954+ },
36955+ pk,
36956+ ))
36957+ })
36958+ .optional()?;
36959+
36960+ Ok(ret)
36961+ }
36962+
36963+ /// Helper function to send a template reply.
36964+ pub fn send_reply_with_list_template<'ctx, F: Fn() -> Template>(
36965+ &self,
36966+ render_context: TemplateRenderContext<'ctx, F>,
36967+ recipients: impl Iterator<Item = Cow<'ctx, melib::Address>>,
36968+ ) -> Result<()> {
36969+ let TemplateRenderContext {
36970+ template,
36971+ default_fn,
36972+ list,
36973+ context,
36974+ queue,
36975+ comment,
36976+ } = render_context;
36977+
36978+ let post_policy = self.list_post_policy(list.pk)?;
36979+ let subscription_policy = self.list_subscription_policy(list.pk)?;
36980+
36981+ let templ = self
36982+ .fetch_template(template, Some(list.pk))?
36983+ .map(DbVal::into_inner)
36984+ .or_else(|| default_fn.map(|f| f()))
36985+ .ok_or_else(|| -> crate::Error {
36986+ format!("Template with name {template:?} was not found.").into()
36987+ })?;
36988+
36989+ let mut draft = templ.render(context)?;
36990+ draft
36991+ .headers
36992+ .insert(melib::HeaderName::FROM, list.request_subaddr());
36993+ for addr in recipients {
36994+ let mut draft = draft.clone();
36995+ draft
36996+ .headers
36997+ .insert(melib::HeaderName::TO, addr.to_string());
36998+ list.insert_headers(
36999+ &mut draft,
37000+ post_policy.as_deref(),
37001+ subscription_policy.as_deref(),
37002+ );
37003+ self.insert_to_queue(QueueEntry::new(
37004+ queue,
37005+ Some(list.pk),
37006+ None,
37007+ draft.finalise()?.as_bytes(),
37008+ Some(comment.to_string()),
37009+ )?)?;
37010+ }
37011+ Ok(())
37012+ }
37013+
37014+ /// Send subscription confirmation.
37015+ pub fn send_subscription_confirmation(
37016+ &self,
37017+ list: &DbVal<MailingList>,
37018+ address: &melib::Address,
37019+ ) -> Result<()> {
37020+ log::trace!(
37021+ "Added subscription to list {list:?} for address {address:?}, sending confirmation."
37022+ );
37023+ self.send_reply_with_list_template(
37024+ TemplateRenderContext {
37025+ template: Template::SUBSCRIPTION_CONFIRMATION,
37026+ default_fn: Some(Template::default_subscription_confirmation),
37027+ list,
37028+ context: minijinja::context! {
37029+ list => &list,
37030+ },
37031+ queue: Queue::Out,
37032+ comment: Template::SUBSCRIPTION_CONFIRMATION.into(),
37033+ },
37034+ std::iter::once(Cow::Borrowed(address)),
37035+ )
37036+ }
37037+
37038+ /// Send unsubscription confirmation.
37039+ pub fn send_unsubscription_confirmation(
37040+ &self,
37041+ list: &DbVal<MailingList>,
37042+ address: &melib::Address,
37043+ ) -> Result<()> {
37044+ log::trace!(
37045+ "Removed subscription to list {list:?} for address {address:?}, sending confirmation."
37046+ );
37047+ self.send_reply_with_list_template(
37048+ TemplateRenderContext {
37049+ template: Template::UNSUBSCRIPTION_CONFIRMATION,
37050+ default_fn: Some(Template::default_unsubscription_confirmation),
37051+ list,
37052+ context: minijinja::context! {
37053+ list => &list,
37054+ },
37055+ queue: Queue::Out,
37056+ comment: Template::UNSUBSCRIPTION_CONFIRMATION.into(),
37057+ },
37058+ std::iter::once(Cow::Borrowed(address)),
37059+ )
37060+ }
37061+ }
37062+
37063+ /// Helper type for [`Connection::send_reply_with_list_template`].
37064+ #[derive(Debug)]
37065+ pub struct TemplateRenderContext<'ctx, F: Fn() -> Template> {
37066+ /// Template name.
37067+ pub template: &'ctx str,
37068+ /// If template is not found, call a function that returns one.
37069+ pub default_fn: Option<F>,
37070+ /// The pertinent list.
37071+ pub list: &'ctx DbVal<MailingList>,
37072+ /// [`minijinja`]'s template context.
37073+ pub context: minijinja::value::Value,
37074+ /// Destination queue in the database.
37075+ pub queue: Queue,
37076+ /// Comment for the queue entry in the database.
37077+ pub comment: Cow<'static, str>,
37078+ }
37079 diff --git a/mailpot/src/queue.rs b/mailpot/src/queue.rs
37080new file mode 100644
37081index 0000000..25311fc
37082--- /dev/null
37083+++ b/mailpot/src/queue.rs
37084 @@ -0,0 +1,370 @@
37085+ /*
37086+ * This file is part of mailpot
37087+ *
37088+ * Copyright 2020 - Manos Pitsidianakis
37089+ *
37090+ * This program is free software: you can redistribute it and/or modify
37091+ * it under the terms of the GNU Affero General Public License as
37092+ * published by the Free Software Foundation, either version 3 of the
37093+ * License, or (at your option) any later version.
37094+ *
37095+ * This program is distributed in the hope that it will be useful,
37096+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
37097+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
37098+ * GNU Affero General Public License for more details.
37099+ *
37100+ * You should have received a copy of the GNU Affero General Public License
37101+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
37102+ */
37103+
37104+ //! # Queues
37105+
37106+ use std::borrow::Cow;
37107+
37108+ use melib::Envelope;
37109+
37110+ use crate::{errors::*, models::DbVal, Connection, DateTime};
37111+
37112+ /// In-database queues of mail.
37113+ #[derive(Copy, Clone, Eq, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
37114+ #[serde(rename_all = "kebab-case")]
37115+ pub enum Queue {
37116+ /// Messages that have been received but not yet processed, await
37117+ /// processing in the `maildrop` queue. Messages can be added to the
37118+ /// `maildrop` queue even when mailpot is not running.
37119+ Maildrop,
37120+ /// List administrators may introduce rules for emails to be placed
37121+ /// indefinitely in the `hold` queue. Messages placed in the `hold`
37122+ /// queue stay there until the administrator intervenes. No periodic
37123+ /// delivery attempts are made for messages in the `hold` queue.
37124+ Hold,
37125+ /// When all the deliverable recipients for a message are delivered, and for
37126+ /// some recipients delivery failed for a transient reason (it might
37127+ /// succeed later), the message is placed in the `deferred` queue.
37128+ Deferred,
37129+ /// Invalid received or generated e-mail saved for debug and troubleshooting
37130+ /// reasons.
37131+ Corrupt,
37132+ /// Emails that must be sent as soon as possible.
37133+ Out,
37134+ /// Error queue
37135+ Error,
37136+ }
37137+
37138+ impl std::str::FromStr for Queue {
37139+ type Err = Error;
37140+
37141+ fn from_str(s: &str) -> Result<Self> {
37142+ Ok(match s.trim() {
37143+ s if s.eq_ignore_ascii_case(stringify!(Maildrop)) => Self::Maildrop,
37144+ s if s.eq_ignore_ascii_case(stringify!(Hold)) => Self::Hold,
37145+ s if s.eq_ignore_ascii_case(stringify!(Deferred)) => Self::Deferred,
37146+ s if s.eq_ignore_ascii_case(stringify!(Corrupt)) => Self::Corrupt,
37147+ s if s.eq_ignore_ascii_case(stringify!(Out)) => Self::Out,
37148+ s if s.eq_ignore_ascii_case(stringify!(Error)) => Self::Error,
37149+ other => return Err(Error::new_external(format!("Invalid Queue name: {other}."))),
37150+ })
37151+ }
37152+ }
37153+
37154+ impl Queue {
37155+ /// Returns the name of the queue used in the database schema.
37156+ pub const fn as_str(&self) -> &'static str {
37157+ match self {
37158+ Self::Maildrop => "maildrop",
37159+ Self::Hold => "hold",
37160+ Self::Deferred => "deferred",
37161+ Self::Corrupt => "corrupt",
37162+ Self::Out => "out",
37163+ Self::Error => "error",
37164+ }
37165+ }
37166+
37167+ /// Returns all possible variants as `&'static str`
37168+ pub const fn possible_values() -> &'static [&'static str] {
37169+ const VALUES: &[&str] = &[
37170+ Queue::Maildrop.as_str(),
37171+ Queue::Hold.as_str(),
37172+ Queue::Deferred.as_str(),
37173+ Queue::Corrupt.as_str(),
37174+ Queue::Out.as_str(),
37175+ Queue::Error.as_str(),
37176+ ];
37177+ VALUES
37178+ }
37179+ }
37180+
37181+ impl std::fmt::Display for Queue {
37182+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
37183+ write!(fmt, "{}", self.as_str())
37184+ }
37185+ }
37186+
37187+ /// A queue entry.
37188+ #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
37189+ pub struct QueueEntry {
37190+ /// Database primary key.
37191+ pub pk: i64,
37192+ /// Owner queue.
37193+ pub queue: Queue,
37194+ /// Related list foreign key, optional.
37195+ pub list: Option<i64>,
37196+ /// Entry comment, optional.
37197+ pub comment: Option<String>,
37198+ /// Entry recipients in rfc5322 format.
37199+ pub to_addresses: String,
37200+ /// Entry submitter in rfc5322 format.
37201+ pub from_address: String,
37202+ /// Entry subject.
37203+ pub subject: String,
37204+ /// Entry Message-ID in rfc5322 format.
37205+ pub message_id: String,
37206+ /// Message in rfc5322 format as bytes.
37207+ pub message: Vec<u8>,
37208+ /// Unix timestamp of date.
37209+ pub timestamp: u64,
37210+ /// Datetime as string.
37211+ pub datetime: DateTime,
37212+ }
37213+
37214+ impl std::fmt::Display for QueueEntry {
37215+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
37216+ write!(fmt, "{:?}", self)
37217+ }
37218+ }
37219+
37220+ impl std::fmt::Debug for QueueEntry {
37221+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
37222+ fmt.debug_struct(stringify!(QueueEntry))
37223+ .field("pk", &self.pk)
37224+ .field("queue", &self.queue)
37225+ .field("list", &self.list)
37226+ .field("comment", &self.comment)
37227+ .field("to_addresses", &self.to_addresses)
37228+ .field("from_address", &self.from_address)
37229+ .field("subject", &self.subject)
37230+ .field("message_id", &self.message_id)
37231+ .field("message length", &self.message.len())
37232+ .field(
37233+ "message",
37234+ &format!("{:.15}", String::from_utf8_lossy(&self.message)),
37235+ )
37236+ .field("timestamp", &self.timestamp)
37237+ .field("datetime", &self.datetime)
37238+ .finish()
37239+ }
37240+ }
37241+
37242+ impl QueueEntry {
37243+ /// Create new entry.
37244+ pub fn new(
37245+ queue: Queue,
37246+ list: Option<i64>,
37247+ env: Option<Cow<'_, Envelope>>,
37248+ raw: &[u8],
37249+ comment: Option<String>,
37250+ ) -> Result<Self> {
37251+ let env = env
37252+ .map(Ok)
37253+ .unwrap_or_else(|| melib::Envelope::from_bytes(raw, None).map(Cow::Owned))?;
37254+ let now = chrono::offset::Utc::now();
37255+ Ok(Self {
37256+ pk: -1,
37257+ list,
37258+ queue,
37259+ comment,
37260+ to_addresses: env.field_to_to_string(),
37261+ from_address: env.field_from_to_string(),
37262+ subject: env.subject().to_string(),
37263+ message_id: env.message_id().to_string(),
37264+ message: raw.to_vec(),
37265+ timestamp: now.timestamp() as u64,
37266+ datetime: now,
37267+ })
37268+ }
37269+ }
37270+
37271+ impl Connection {
37272+ /// Insert a received email into a queue.
37273+ pub fn insert_to_queue(&self, mut entry: QueueEntry) -> Result<DbVal<QueueEntry>> {
37274+ log::trace!("Inserting to queue: {entry}");
37275+ let mut stmt = self.connection.prepare(
37276+ "INSERT INTO queue(which, list, comment, to_addresses, from_address, subject, \
37277+ message_id, message, timestamp, datetime) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
37278+ RETURNING pk;",
37279+ )?;
37280+ let pk = stmt.query_row(
37281+ rusqlite::params![
37282+ entry.queue.as_str(),
37283+ &entry.list,
37284+ &entry.comment,
37285+ &entry.to_addresses,
37286+ &entry.from_address,
37287+ &entry.subject,
37288+ &entry.message_id,
37289+ &entry.message,
37290+ &entry.timestamp,
37291+ &entry.datetime,
37292+ ],
37293+ |row| {
37294+ let pk: i64 = row.get("pk")?;
37295+ Ok(pk)
37296+ },
37297+ )?;
37298+ entry.pk = pk;
37299+ Ok(DbVal(entry, pk))
37300+ }
37301+
37302+ /// Fetch all queue entries.
37303+ pub fn queue(&self, queue: Queue) -> Result<Vec<DbVal<QueueEntry>>> {
37304+ let mut stmt = self
37305+ .connection
37306+ .prepare("SELECT * FROM queue WHERE which = ?;")?;
37307+ let iter = stmt.query_map([&queue.as_str()], |row| {
37308+ let pk = row.get::<_, i64>("pk")?;
37309+ Ok(DbVal(
37310+ QueueEntry {
37311+ pk,
37312+ queue,
37313+ list: row.get::<_, Option<i64>>("list")?,
37314+ comment: row.get::<_, Option<String>>("comment")?,
37315+ to_addresses: row.get::<_, String>("to_addresses")?,
37316+ from_address: row.get::<_, String>("from_address")?,
37317+ subject: row.get::<_, String>("subject")?,
37318+ message_id: row.get::<_, String>("message_id")?,
37319+ message: row.get::<_, Vec<u8>>("message")?,
37320+ timestamp: row.get::<_, u64>("timestamp")?,
37321+ datetime: row.get::<_, DateTime>("datetime")?,
37322+ },
37323+ pk,
37324+ ))
37325+ })?;
37326+
37327+ let mut ret = vec![];
37328+ for item in iter {
37329+ let item = item?;
37330+ ret.push(item);
37331+ }
37332+ Ok(ret)
37333+ }
37334+
37335+ /// Delete queue entries returning the deleted values.
37336+ pub fn delete_from_queue(&self, queue: Queue, index: Vec<i64>) -> Result<Vec<QueueEntry>> {
37337+ let tx = self.savepoint(Some(stringify!(delete_from_queue)))?;
37338+
37339+ let cl = |row: &rusqlite::Row<'_>| {
37340+ Ok(QueueEntry {
37341+ pk: -1,
37342+ queue,
37343+ list: row.get::<_, Option<i64>>("list")?,
37344+ comment: row.get::<_, Option<String>>("comment")?,
37345+ to_addresses: row.get::<_, String>("to_addresses")?,
37346+ from_address: row.get::<_, String>("from_address")?,
37347+ subject: row.get::<_, String>("subject")?,
37348+ message_id: row.get::<_, String>("message_id")?,
37349+ message: row.get::<_, Vec<u8>>("message")?,
37350+ timestamp: row.get::<_, u64>("timestamp")?,
37351+ datetime: row.get::<_, DateTime>("datetime")?,
37352+ })
37353+ };
37354+ let mut stmt = if index.is_empty() {
37355+ tx.connection
37356+ .prepare("DELETE FROM queue WHERE which = ? RETURNING *;")?
37357+ } else {
37358+ tx.connection
37359+ .prepare("DELETE FROM queue WHERE which = ? AND pk IN rarray(?) RETURNING *;")?
37360+ };
37361+ let iter = if index.is_empty() {
37362+ stmt.query_map([&queue.as_str()], cl)?
37363+ } else {
37364+ // Note: A `Rc<Vec<Value>>` must be used as the parameter.
37365+ let index = std::rc::Rc::new(
37366+ index
37367+ .into_iter()
37368+ .map(rusqlite::types::Value::from)
37369+ .collect::<Vec<rusqlite::types::Value>>(),
37370+ );
37371+ stmt.query_map(rusqlite::params![queue.as_str(), index], cl)?
37372+ };
37373+
37374+ let mut ret = vec![];
37375+ for item in iter {
37376+ let item = item?;
37377+ ret.push(item);
37378+ }
37379+ drop(stmt);
37380+ tx.commit()?;
37381+ Ok(ret)
37382+ }
37383+ }
37384+
37385+ #[cfg(test)]
37386+ mod tests {
37387+ use super::*;
37388+ use crate::*;
37389+
37390+ #[test]
37391+ fn test_queue_delete_array() {
37392+ use tempfile::TempDir;
37393+
37394+ let tmp_dir = TempDir::new().unwrap();
37395+ let db_path = tmp_dir.path().join("mpot.db");
37396+ let config = Configuration {
37397+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
37398+ db_path,
37399+ data_path: tmp_dir.path().to_path_buf(),
37400+ administrators: vec![],
37401+ };
37402+
37403+ let db = Connection::open_or_create_db(config).unwrap().trusted();
37404+ for i in 0..5 {
37405+ db.insert_to_queue(
37406+ QueueEntry::new(
37407+ Queue::Hold,
37408+ None,
37409+ None,
37410+ format!("Subject: testing\r\nMessage-Id: {i}@localhost\r\n\r\nHello\r\n")
37411+ .as_bytes(),
37412+ None,
37413+ )
37414+ .unwrap(),
37415+ )
37416+ .unwrap();
37417+ }
37418+ let entries = db.queue(Queue::Hold).unwrap();
37419+ assert_eq!(entries.len(), 5);
37420+ let out_entries = db.delete_from_queue(Queue::Out, vec![]).unwrap();
37421+ assert_eq!(db.queue(Queue::Hold).unwrap().len(), 5);
37422+ assert!(out_entries.is_empty());
37423+ let deleted_entries = db.delete_from_queue(Queue::Hold, vec![]).unwrap();
37424+ assert_eq!(deleted_entries.len(), 5);
37425+ assert_eq!(
37426+ &entries
37427+ .iter()
37428+ .cloned()
37429+ .map(DbVal::into_inner)
37430+ .map(|mut e| {
37431+ e.pk = -1;
37432+ e
37433+ })
37434+ .collect::<Vec<_>>(),
37435+ &deleted_entries
37436+ );
37437+
37438+ for e in deleted_entries {
37439+ db.insert_to_queue(e).unwrap();
37440+ }
37441+
37442+ let index = db
37443+ .queue(Queue::Hold)
37444+ .unwrap()
37445+ .into_iter()
37446+ .skip(2)
37447+ .map(|e| e.pk())
37448+ .take(2)
37449+ .collect::<Vec<i64>>();
37450+ let deleted_entries = db.delete_from_queue(Queue::Hold, index).unwrap();
37451+ assert_eq!(deleted_entries.len(), 2);
37452+ assert_eq!(db.queue(Queue::Hold).unwrap().len(), 3);
37453+ }
37454+ }
37455 diff --git a/mailpot/src/schema.sql b/mailpot/src/schema.sql
37456new file mode 100644
37457index 0000000..52e6d34
37458--- /dev/null
37459+++ b/mailpot/src/schema.sql
37460 @@ -0,0 +1,657 @@
37461+ PRAGMA foreign_keys = true;
37462+ PRAGMA encoding = 'UTF-8';
37463+
37464+ CREATE TABLE IF NOT EXISTS list (
37465+ pk INTEGER PRIMARY KEY NOT NULL,
37466+ name TEXT NOT NULL,
37467+ id TEXT NOT NULL UNIQUE,
37468+ address TEXT NOT NULL UNIQUE,
37469+ owner_local_part TEXT,
37470+ request_local_part TEXT,
37471+ archive_url TEXT,
37472+ description TEXT,
37473+ topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
37474+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37475+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
37476+ verify BOOLEAN CHECK (verify IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
37477+ hidden BOOLEAN CHECK (hidden IN (0, 1)) NOT NULL DEFAULT 0,
37478+ enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1
37479+ );
37480+
37481+ CREATE TABLE IF NOT EXISTS owner (
37482+ pk INTEGER PRIMARY KEY NOT NULL,
37483+ list INTEGER NOT NULL,
37484+ address TEXT NOT NULL,
37485+ name TEXT,
37486+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37487+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
37488+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
37489+ );
37490+
37491+ CREATE TABLE IF NOT EXISTS post_policy (
37492+ pk INTEGER PRIMARY KEY NOT NULL,
37493+ list INTEGER NOT NULL UNIQUE,
37494+ announce_only BOOLEAN CHECK (announce_only IN (0, 1)) NOT NULL
37495+ DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
37496+ subscription_only BOOLEAN CHECK (subscription_only IN (0, 1)) NOT NULL
37497+ DEFAULT 0,
37498+ approval_needed BOOLEAN CHECK (approval_needed IN (0, 1)) NOT NULL
37499+ DEFAULT 0,
37500+ open BOOLEAN CHECK (open IN (0, 1)) NOT NULL DEFAULT 0,
37501+ custom BOOLEAN CHECK (custom IN (0, 1)) NOT NULL DEFAULT 0,
37502+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37503+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
37504+ CHECK((
37505+ (custom) OR ((
37506+ (open) OR ((
37507+ (approval_needed) OR ((
37508+ (announce_only) OR (subscription_only)
37509+ )
37510+ AND NOT
37511+ (
37512+ (announce_only) AND (subscription_only)
37513+ ))
37514+ )
37515+ AND NOT
37516+ (
37517+ (approval_needed) AND ((
37518+ (announce_only) OR (subscription_only)
37519+ )
37520+ AND NOT
37521+ (
37522+ (announce_only) AND (subscription_only)
37523+ ))
37524+ ))
37525+ )
37526+ AND NOT
37527+ (
37528+ (open) AND ((
37529+ (approval_needed) OR ((
37530+ (announce_only) OR (subscription_only)
37531+ )
37532+ AND NOT
37533+ (
37534+ (announce_only) AND (subscription_only)
37535+ ))
37536+ )
37537+ AND NOT
37538+ (
37539+ (approval_needed) AND ((
37540+ (announce_only) OR (subscription_only)
37541+ )
37542+ AND NOT
37543+ (
37544+ (announce_only) AND (subscription_only)
37545+ ))
37546+ ))
37547+ ))
37548+ )
37549+ AND NOT
37550+ (
37551+ (custom) AND ((
37552+ (open) OR ((
37553+ (approval_needed) OR ((
37554+ (announce_only) OR (subscription_only)
37555+ )
37556+ AND NOT
37557+ (
37558+ (announce_only) AND (subscription_only)
37559+ ))
37560+ )
37561+ AND NOT
37562+ (
37563+ (approval_needed) AND ((
37564+ (announce_only) OR (subscription_only)
37565+ )
37566+ AND NOT
37567+ (
37568+ (announce_only) AND (subscription_only)
37569+ ))
37570+ ))
37571+ )
37572+ AND NOT
37573+ (
37574+ (open) AND ((
37575+ (approval_needed) OR ((
37576+ (announce_only) OR (subscription_only)
37577+ )
37578+ AND NOT
37579+ (
37580+ (announce_only) AND (subscription_only)
37581+ ))
37582+ )
37583+ AND NOT
37584+ (
37585+ (approval_needed) AND ((
37586+ (announce_only) OR (subscription_only)
37587+ )
37588+ AND NOT
37589+ (
37590+ (announce_only) AND (subscription_only)
37591+ ))
37592+ ))
37593+ ))
37594+ )),
37595+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
37596+ );
37597+
37598+ CREATE TABLE IF NOT EXISTS subscription_policy (
37599+ pk INTEGER PRIMARY KEY NOT NULL,
37600+ list INTEGER NOT NULL UNIQUE,
37601+ send_confirmation BOOLEAN CHECK (send_confirmation IN (0, 1)) NOT NULL
37602+ DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
37603+ open BOOLEAN CHECK (open IN (0, 1)) NOT NULL DEFAULT 0,
37604+ manual BOOLEAN CHECK (manual IN (0, 1)) NOT NULL DEFAULT 0,
37605+ request BOOLEAN CHECK (request IN (0, 1)) NOT NULL DEFAULT 0,
37606+ custom BOOLEAN CHECK (custom IN (0, 1)) NOT NULL DEFAULT 0,
37607+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37608+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
37609+ CHECK((
37610+ (open) OR ((
37611+ (manual) OR ((
37612+ (request) OR (custom)
37613+ )
37614+ AND NOT
37615+ (
37616+ (request) AND (custom)
37617+ ))
37618+ )
37619+ AND NOT
37620+ (
37621+ (manual) AND ((
37622+ (request) OR (custom)
37623+ )
37624+ AND NOT
37625+ (
37626+ (request) AND (custom)
37627+ ))
37628+ ))
37629+ )
37630+ AND NOT
37631+ (
37632+ (open) AND ((
37633+ (manual) OR ((
37634+ (request) OR (custom)
37635+ )
37636+ AND NOT
37637+ (
37638+ (request) AND (custom)
37639+ ))
37640+ )
37641+ AND NOT
37642+ (
37643+ (manual) AND ((
37644+ (request) OR (custom)
37645+ )
37646+ AND NOT
37647+ (
37648+ (request) AND (custom)
37649+ ))
37650+ ))
37651+ )),
37652+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
37653+ );
37654+
37655+ CREATE TABLE IF NOT EXISTS subscription (
37656+ pk INTEGER PRIMARY KEY NOT NULL,
37657+ list INTEGER NOT NULL,
37658+ address TEXT NOT NULL,
37659+ name TEXT,
37660+ account INTEGER,
37661+ enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL
37662+ DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
37663+ verified BOOLEAN CHECK (verified IN (0, 1)) NOT NULL
37664+ DEFAULT 1,
37665+ digest BOOLEAN CHECK (digest IN (0, 1)) NOT NULL
37666+ DEFAULT 0,
37667+ hide_address BOOLEAN CHECK (hide_address IN (0, 1)) NOT NULL
37668+ DEFAULT 0,
37669+ receive_duplicates BOOLEAN CHECK (receive_duplicates IN (0, 1)) NOT NULL
37670+ DEFAULT 1,
37671+ receive_own_posts BOOLEAN CHECK (receive_own_posts IN (0, 1)) NOT NULL
37672+ DEFAULT 0,
37673+ receive_confirmation BOOLEAN CHECK (receive_confirmation IN (0, 1)) NOT NULL
37674+ DEFAULT 1,
37675+ last_digest INTEGER NOT NULL DEFAULT (unixepoch()),
37676+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37677+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
37678+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
37679+ FOREIGN KEY (account) REFERENCES account(pk) ON DELETE SET NULL,
37680+ UNIQUE (list, address) ON CONFLICT ROLLBACK
37681+ );
37682+
37683+ CREATE TABLE IF NOT EXISTS account (
37684+ pk INTEGER PRIMARY KEY NOT NULL,
37685+ name TEXT,
37686+ address TEXT NOT NULL UNIQUE,
37687+ public_key TEXT,
37688+ password TEXT NOT NULL,
37689+ enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
37690+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37691+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
37692+ );
37693+
37694+ CREATE TABLE IF NOT EXISTS candidate_subscription (
37695+ pk INTEGER PRIMARY KEY NOT NULL,
37696+ list INTEGER NOT NULL,
37697+ address TEXT NOT NULL,
37698+ name TEXT,
37699+ accepted INTEGER UNIQUE,
37700+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37701+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
37702+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
37703+ FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
37704+ UNIQUE (list, address) ON CONFLICT ROLLBACK
37705+ );
37706+
37707+ CREATE TABLE IF NOT EXISTS post (
37708+ pk INTEGER PRIMARY KEY NOT NULL,
37709+ list INTEGER NOT NULL,
37710+ envelope_from TEXT,
37711+ address TEXT NOT NULL,
37712+ message_id TEXT NOT NULL,
37713+ message BLOB NOT NULL,
37714+ headers_json TEXT,
37715+ timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
37716+ datetime TEXT NOT NULL DEFAULT (datetime()),
37717+ created INTEGER NOT NULL DEFAULT (unixepoch())
37718+ );
37719+
37720+ CREATE TABLE IF NOT EXISTS template (
37721+ pk INTEGER PRIMARY KEY NOT NULL,
37722+ name TEXT NOT NULL,
37723+ list INTEGER,
37724+ subject TEXT,
37725+ headers_json TEXT,
37726+ body TEXT NOT NULL,
37727+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37728+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
37729+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
37730+ UNIQUE (list, name) ON CONFLICT ROLLBACK
37731+ );
37732+
37733+ CREATE TABLE IF NOT EXISTS settings_json_schema (
37734+ pk INTEGER PRIMARY KEY NOT NULL,
37735+ id TEXT NOT NULL UNIQUE,
37736+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
37737+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37738+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
37739+ );
37740+
37741+ CREATE TABLE IF NOT EXISTS list_settings_json (
37742+ pk INTEGER PRIMARY KEY NOT NULL,
37743+ name TEXT NOT NULL,
37744+ list INTEGER,
37745+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
37746+ is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
37747+ created INTEGER NOT NULL DEFAULT (unixepoch()),
37748+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
37749+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
37750+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
37751+ UNIQUE (list, name) ON CONFLICT ROLLBACK
37752+ );
37753+
37754+ CREATE TRIGGER
37755+ IF NOT EXISTS is_valid_settings_json_on_update
37756+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
37757+ FOR EACH ROW
37758+ BEGIN
37759+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
37760+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
37761+ END;
37762+
37763+ CREATE TRIGGER
37764+ IF NOT EXISTS is_valid_settings_json_on_insert
37765+ AFTER INSERT ON list_settings_json
37766+ FOR EACH ROW
37767+ BEGIN
37768+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
37769+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
37770+ END;
37771+
37772+ CREATE TRIGGER
37773+ IF NOT EXISTS invalidate_settings_json_on_schema_update
37774+ AFTER UPDATE OF value, id ON settings_json_schema
37775+ FOR EACH ROW
37776+ BEGIN
37777+ UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
37778+ END;
37779+
37780+ -- # Queues
37781+ --
37782+ -- ## The "maildrop" queue
37783+ --
37784+ -- Messages that have been submitted but not yet processed, await processing
37785+ -- in the "maildrop" queue. Messages can be added to the "maildrop" queue
37786+ -- even when mailpot is not running.
37787+ --
37788+ -- ## The "deferred" queue
37789+ --
37790+ -- When all the deliverable recipients for a message are delivered, and for
37791+ -- some recipients delivery failed for a transient reason (it might succeed
37792+ -- later), the message is placed in the "deferred" queue.
37793+ --
37794+ -- ## The "hold" queue
37795+ --
37796+ -- List administrators may introduce rules for emails to be placed
37797+ -- indefinitely in the "hold" queue. Messages placed in the "hold" queue stay
37798+ -- there until the administrator intervenes. No periodic delivery attempts
37799+ -- are made for messages in the "hold" queue.
37800+
37801+ -- ## The "out" queue
37802+ --
37803+ -- Emails that must be sent as soon as possible.
37804+ CREATE TABLE IF NOT EXISTS queue (
37805+ pk INTEGER PRIMARY KEY NOT NULL,
37806+ which TEXT
37807+ CHECK (
37808+ which IN
37809+ ('maildrop',
37810+ 'hold',
37811+ 'deferred',
37812+ 'corrupt',
37813+ 'error',
37814+ 'out')
37815+ ) NOT NULL,
37816+ list INTEGER,
37817+ comment TEXT,
37818+ to_addresses TEXT NOT NULL,
37819+ from_address TEXT NOT NULL,
37820+ subject TEXT NOT NULL,
37821+ message_id TEXT NOT NULL,
37822+ message BLOB NOT NULL,
37823+ timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
37824+ datetime TEXT NOT NULL DEFAULT (datetime()),
37825+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
37826+ UNIQUE (to_addresses, message_id) ON CONFLICT ROLLBACK
37827+ );
37828+
37829+ CREATE TABLE IF NOT EXISTS bounce (
37830+ pk INTEGER PRIMARY KEY NOT NULL,
37831+ subscription INTEGER NOT NULL UNIQUE,
37832+ count INTEGER NOT NULL DEFAULT 0,
37833+ last_bounce TEXT NOT NULL DEFAULT (datetime()),
37834+ FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
37835+ );
37836+
37837+ CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
37838+ CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
37839+ CREATE INDEX IF NOT EXISTS list_idx ON list(id);
37840+ CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
37841+
37842+ -- [tag:accept_candidate]: Update candidacy with 'subscription' foreign key on
37843+ -- 'subscription' insert.
37844+ CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
37845+ FOR EACH ROW
37846+ BEGIN
37847+ UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
37848+ WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
37849+ END;
37850+
37851+ -- [tag:verify_subscription_email]: If list settings require e-mail to be
37852+ -- verified, update new subscription's 'verify' column value.
37853+ CREATE TRIGGER IF NOT EXISTS verify_subscription_email AFTER INSERT ON subscription
37854+ FOR EACH ROW
37855+ BEGIN
37856+ UPDATE subscription
37857+ SET verified = 0, last_modified = unixepoch()
37858+ WHERE
37859+ subscription.pk = NEW.pk
37860+ AND
37861+ EXISTS
37862+ (SELECT 1 FROM list WHERE pk = NEW.list AND verify = 1);
37863+ END;
37864+
37865+ -- [tag:add_account]: Update list subscription entries with 'account' foreign
37866+ -- key, if addresses match.
37867+ CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
37868+ FOR EACH ROW
37869+ BEGIN
37870+ UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
37871+ WHERE subscription.address = NEW.address;
37872+ END;
37873+
37874+ -- [tag:add_account_to_subscription]: When adding a new 'subscription', auto
37875+ -- set 'account' value if there already exists an 'account' entry with the
37876+ -- same address.
37877+ CREATE TRIGGER IF NOT EXISTS add_account_to_subscription
37878+ AFTER INSERT ON subscription
37879+ FOR EACH ROW
37880+ WHEN
37881+ NEW.account IS NULL
37882+ AND EXISTS (SELECT 1 FROM account WHERE address = NEW.address)
37883+ BEGIN
37884+ UPDATE subscription
37885+ SET account = (SELECT pk FROM account WHERE address = NEW.address),
37886+ last_modified = unixepoch()
37887+ WHERE subscription.pk = NEW.pk;
37888+ END;
37889+
37890+
37891+ -- [tag:last_modified_list]: update last_modified on every change.
37892+ CREATE TRIGGER
37893+ IF NOT EXISTS last_modified_list
37894+ AFTER UPDATE ON list
37895+ FOR EACH ROW
37896+ WHEN NEW.last_modified == OLD.last_modified
37897+ BEGIN
37898+ UPDATE list SET last_modified = unixepoch()
37899+ WHERE pk = NEW.pk;
37900+ END;
37901+
37902+ -- [tag:last_modified_owner]: update last_modified on every change.
37903+ CREATE TRIGGER
37904+ IF NOT EXISTS last_modified_owner
37905+ AFTER UPDATE ON owner
37906+ FOR EACH ROW
37907+ WHEN NEW.last_modified == OLD.last_modified
37908+ BEGIN
37909+ UPDATE owner SET last_modified = unixepoch()
37910+ WHERE pk = NEW.pk;
37911+ END;
37912+
37913+ -- [tag:last_modified_post_policy]: update last_modified on every change.
37914+ CREATE TRIGGER
37915+ IF NOT EXISTS last_modified_post_policy
37916+ AFTER UPDATE ON post_policy
37917+ FOR EACH ROW
37918+ WHEN NEW.last_modified == OLD.last_modified
37919+ BEGIN
37920+ UPDATE post_policy SET last_modified = unixepoch()
37921+ WHERE pk = NEW.pk;
37922+ END;
37923+
37924+ -- [tag:last_modified_subscription_policy]: update last_modified on every change.
37925+ CREATE TRIGGER
37926+ IF NOT EXISTS last_modified_subscription_policy
37927+ AFTER UPDATE ON subscription_policy
37928+ FOR EACH ROW
37929+ WHEN NEW.last_modified == OLD.last_modified
37930+ BEGIN
37931+ UPDATE subscription_policy SET last_modified = unixepoch()
37932+ WHERE pk = NEW.pk;
37933+ END;
37934+
37935+ -- [tag:last_modified_subscription]: update last_modified on every change.
37936+ CREATE TRIGGER
37937+ IF NOT EXISTS last_modified_subscription
37938+ AFTER UPDATE ON subscription
37939+ FOR EACH ROW
37940+ WHEN NEW.last_modified == OLD.last_modified
37941+ BEGIN
37942+ UPDATE subscription SET last_modified = unixepoch()
37943+ WHERE pk = NEW.pk;
37944+ END;
37945+
37946+ -- [tag:last_modified_account]: update last_modified on every change.
37947+ CREATE TRIGGER
37948+ IF NOT EXISTS last_modified_account
37949+ AFTER UPDATE ON account
37950+ FOR EACH ROW
37951+ WHEN NEW.last_modified == OLD.last_modified
37952+ BEGIN
37953+ UPDATE account SET last_modified = unixepoch()
37954+ WHERE pk = NEW.pk;
37955+ END;
37956+
37957+ -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
37958+ CREATE TRIGGER
37959+ IF NOT EXISTS last_modified_candidate_subscription
37960+ AFTER UPDATE ON candidate_subscription
37961+ FOR EACH ROW
37962+ WHEN NEW.last_modified == OLD.last_modified
37963+ BEGIN
37964+ UPDATE candidate_subscription SET last_modified = unixepoch()
37965+ WHERE pk = NEW.pk;
37966+ END;
37967+
37968+ -- [tag:last_modified_template]: update last_modified on every change.
37969+ CREATE TRIGGER
37970+ IF NOT EXISTS last_modified_template
37971+ AFTER UPDATE ON template
37972+ FOR EACH ROW
37973+ WHEN NEW.last_modified == OLD.last_modified
37974+ BEGIN
37975+ UPDATE template SET last_modified = unixepoch()
37976+ WHERE pk = NEW.pk;
37977+ END;
37978+
37979+ -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
37980+ CREATE TRIGGER
37981+ IF NOT EXISTS last_modified_settings_json_schema
37982+ AFTER UPDATE ON settings_json_schema
37983+ FOR EACH ROW
37984+ WHEN NEW.last_modified == OLD.last_modified
37985+ BEGIN
37986+ UPDATE settings_json_schema SET last_modified = unixepoch()
37987+ WHERE pk = NEW.pk;
37988+ END;
37989+
37990+ -- [tag:last_modified_list_settings_json]: update last_modified on every change.
37991+ CREATE TRIGGER
37992+ IF NOT EXISTS last_modified_list_settings_json
37993+ AFTER UPDATE ON list_settings_json
37994+ FOR EACH ROW
37995+ WHEN NEW.last_modified == OLD.last_modified
37996+ BEGIN
37997+ UPDATE list_settings_json SET last_modified = unixepoch()
37998+ WHERE pk = NEW.pk;
37999+ END;
38000+
38001+ CREATE TRIGGER
38002+ IF NOT EXISTS sort_topics_update_trigger
38003+ AFTER UPDATE ON list
38004+ FOR EACH ROW
38005+ WHEN NEW.topics != OLD.topics
38006+ BEGIN
38007+ UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
38008+ END;
38009+
38010+ CREATE TRIGGER
38011+ IF NOT EXISTS sort_topics_new_trigger
38012+ AFTER INSERT ON list
38013+ FOR EACH ROW
38014+ BEGIN
38015+ UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
38016+ END;
38017+
38018+
38019+ -- 005.data.sql
38020+
38021+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
38022+ "$schema": "http://json-schema.org/draft-07/schema",
38023+ "$ref": "#/$defs/ArchivedAtLinkSettings",
38024+ "$defs": {
38025+ "ArchivedAtLinkSettings": {
38026+ "title": "ArchivedAtLinkSettings",
38027+ "description": "Settings for ArchivedAtLink message filter",
38028+ "type": "object",
38029+ "properties": {
38030+ "template": {
38031+ "title": "Jinja template for header value",
38032+ "description": "Template for\n `Archived-At` header value, as described in RFC 5064 \"The Archived-At\n Message Header Field\". The template receives only one string variable\n with the value of the mailing list post `Message-ID` header.\n\n For example, if:\n\n - the template is `http://www.example.com/mid/{{msg_id}}`\n - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\n The full header will be generated as:\n\n `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\n Note: Surrounding carets in the `Message-ID` value are not required. If\n you wish to preserve them in the URL, set option `preserve-carets` to\n true.\n ",
38033+ "examples": [
38034+ "https://www.example.com/{{msg_id}}",
38035+ "https://www.example.com/{{msg_id}}.html"
38036+ ],
38037+ "type": "string",
38038+ "pattern": ".+[{][{]msg_id[}][}].*"
38039+ },
38040+ "preserve_carets": {
38041+ "title": "Preserve carets of `Message-ID` in generated value",
38042+ "type": "boolean",
38043+ "default": false
38044+ }
38045+ },
38046+ "required": [
38047+ "template"
38048+ ]
38049+ }
38050+ }
38051+ }');
38052+
38053+
38054+ -- 006.data.sql
38055+
38056+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
38057+ "$schema": "http://json-schema.org/draft-07/schema",
38058+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
38059+ "$defs": {
38060+ "AddSubjectTagPrefixSettings": {
38061+ "title": "AddSubjectTagPrefixSettings",
38062+ "description": "Settings for AddSubjectTagPrefix message filter",
38063+ "type": "object",
38064+ "properties": {
38065+ "enabled": {
38066+ "title": "If true, the list subject prefix is added to post subjects.",
38067+ "type": "boolean"
38068+ }
38069+ },
38070+ "required": [
38071+ "enabled"
38072+ ]
38073+ }
38074+ }
38075+ }');
38076+
38077+
38078+ -- 007.data.sql
38079+
38080+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('MimeRejectSettings', '{
38081+ "$schema": "http://json-schema.org/draft-07/schema",
38082+ "$ref": "#/$defs/MimeRejectSettings",
38083+ "$defs": {
38084+ "MimeRejectSettings": {
38085+ "title": "MimeRejectSettings",
38086+ "description": "Settings for MimeReject message filter",
38087+ "type": "object",
38088+ "properties": {
38089+ "enabled": {
38090+ "title": "If true, list posts that contain mime types in the reject array are rejected.",
38091+ "type": "boolean"
38092+ },
38093+ "reject": {
38094+ "title": "Mime types to reject.",
38095+ "type": "array",
38096+ "minLength": 0,
38097+ "items": { "$ref": "#/$defs/MimeType" }
38098+ },
38099+ "required": [
38100+ "enabled"
38101+ ]
38102+ }
38103+ },
38104+ "MimeType": {
38105+ "type": "string",
38106+ "maxLength": 127,
38107+ "minLength": 3,
38108+ "uniqueItems": true,
38109+ "pattern": "^[a-zA-Z!#$&-^_]+[/][a-zA-Z!#$&-^_]+$"
38110+ }
38111+ }
38112+ }');
38113+
38114+
38115+ -- Set current schema version.
38116+
38117+ PRAGMA user_version = 7;
38118 diff --git a/mailpot/src/schema.sql.m4 b/mailpot/src/schema.sql.m4
38119new file mode 100644
38120index 0000000..c89fa8f
38121--- /dev/null
38122+++ b/mailpot/src/schema.sql.m4
38123 @@ -0,0 +1,359 @@
38124+ define(xor, `dnl
38125+ (
38126+ ($1) OR ($2)
38127+ )
38128+ AND NOT
38129+ (
38130+ ($1) AND ($2)
38131+ )')dnl
38132+ dnl
38133+ dnl # Define boolean column types and defaults
38134+ define(BOOLEAN_TYPE, `BOOLEAN CHECK ($1 IN (0, 1)) NOT NULL')dnl
38135+ define(BOOLEAN_FALSE, `0')dnl
38136+ define(BOOLEAN_TRUE, `1')dnl
38137+ define(BOOLEAN_DOCS, ` -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1')dnl
38138+ dnl
38139+ dnl # defile comment functions
38140+ dnl
38141+ dnl # Write the string '['+'tag'+':'+... with a macro so that tagref check
38142+ dnl # doesn't pick up on it as a duplicate.
38143+ define(__TAG, `tag')dnl
38144+ define(TAG, `['__TAG()`:$1]')dnl
38145+ dnl
38146+ dnl # define triggers
38147+ define(update_last_modified, `
38148+ -- 'TAG(last_modified_$1)`: update last_modified on every change.
38149+ CREATE TRIGGER
38150+ IF NOT EXISTS last_modified_$1
38151+ AFTER UPDATE ON $1
38152+ FOR EACH ROW
38153+ WHEN NEW.last_modified == OLD.last_modified
38154+ BEGIN
38155+ UPDATE $1 SET last_modified = unixepoch()
38156+ WHERE pk = NEW.pk;
38157+ END;')dnl
38158+ dnl
38159+ PRAGMA foreign_keys = true;
38160+ PRAGMA encoding = 'UTF-8';
38161+
38162+ CREATE TABLE IF NOT EXISTS list (
38163+ pk INTEGER PRIMARY KEY NOT NULL,
38164+ name TEXT NOT NULL,
38165+ id TEXT NOT NULL UNIQUE,
38166+ address TEXT NOT NULL UNIQUE,
38167+ owner_local_part TEXT,
38168+ request_local_part TEXT,
38169+ archive_url TEXT,
38170+ description TEXT,
38171+ topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
38172+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38173+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
38174+ verify BOOLEAN_TYPE(verify) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
38175+ hidden BOOLEAN_TYPE(hidden) DEFAULT BOOLEAN_FALSE(),
38176+ enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE()
38177+ );
38178+
38179+ CREATE TABLE IF NOT EXISTS owner (
38180+ pk INTEGER PRIMARY KEY NOT NULL,
38181+ list INTEGER NOT NULL,
38182+ address TEXT NOT NULL,
38183+ name TEXT,
38184+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38185+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
38186+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
38187+ );
38188+
38189+ CREATE TABLE IF NOT EXISTS post_policy (
38190+ pk INTEGER PRIMARY KEY NOT NULL,
38191+ list INTEGER NOT NULL UNIQUE,
38192+ announce_only BOOLEAN_TYPE(announce_only)
38193+ DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
38194+ subscription_only BOOLEAN_TYPE(subscription_only)
38195+ DEFAULT BOOLEAN_FALSE(),
38196+ approval_needed BOOLEAN_TYPE(approval_needed)
38197+ DEFAULT BOOLEAN_FALSE(),
38198+ open BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
38199+ custom BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
38200+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38201+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
38202+ CHECK(xor(custom, xor(open, xor(approval_needed, xor(announce_only, subscription_only))))),
38203+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
38204+ );
38205+
38206+ CREATE TABLE IF NOT EXISTS subscription_policy (
38207+ pk INTEGER PRIMARY KEY NOT NULL,
38208+ list INTEGER NOT NULL UNIQUE,
38209+ send_confirmation BOOLEAN_TYPE(send_confirmation)
38210+ DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
38211+ open BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
38212+ manual BOOLEAN_TYPE(manual) DEFAULT BOOLEAN_FALSE(),
38213+ request BOOLEAN_TYPE(request) DEFAULT BOOLEAN_FALSE(),
38214+ custom BOOLEAN_TYPE(custom) DEFAULT BOOLEAN_FALSE(),
38215+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38216+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
38217+ CHECK(xor(open, xor(manual, xor(request, custom)))),
38218+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE
38219+ );
38220+
38221+ CREATE TABLE IF NOT EXISTS subscription (
38222+ pk INTEGER PRIMARY KEY NOT NULL,
38223+ list INTEGER NOT NULL,
38224+ address TEXT NOT NULL,
38225+ name TEXT,
38226+ account INTEGER,
38227+ enabled BOOLEAN_TYPE(enabled)
38228+ DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
38229+ verified BOOLEAN_TYPE(verified)
38230+ DEFAULT BOOLEAN_TRUE(),
38231+ digest BOOLEAN_TYPE(digest)
38232+ DEFAULT BOOLEAN_FALSE(),
38233+ hide_address BOOLEAN_TYPE(hide_address)
38234+ DEFAULT BOOLEAN_FALSE(),
38235+ receive_duplicates BOOLEAN_TYPE(receive_duplicates)
38236+ DEFAULT BOOLEAN_TRUE(),
38237+ receive_own_posts BOOLEAN_TYPE(receive_own_posts)
38238+ DEFAULT BOOLEAN_FALSE(),
38239+ receive_confirmation BOOLEAN_TYPE(receive_confirmation)
38240+ DEFAULT BOOLEAN_TRUE(),
38241+ last_digest INTEGER NOT NULL DEFAULT (unixepoch()),
38242+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38243+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
38244+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
38245+ FOREIGN KEY (account) REFERENCES account(pk) ON DELETE SET NULL,
38246+ UNIQUE (list, address) ON CONFLICT ROLLBACK
38247+ );
38248+
38249+ CREATE TABLE IF NOT EXISTS account (
38250+ pk INTEGER PRIMARY KEY NOT NULL,
38251+ name TEXT,
38252+ address TEXT NOT NULL UNIQUE,
38253+ public_key TEXT,
38254+ password TEXT NOT NULL,
38255+ enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
38256+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38257+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
38258+ );
38259+
38260+ CREATE TABLE IF NOT EXISTS candidate_subscription (
38261+ pk INTEGER PRIMARY KEY NOT NULL,
38262+ list INTEGER NOT NULL,
38263+ address TEXT NOT NULL,
38264+ name TEXT,
38265+ accepted INTEGER UNIQUE,
38266+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38267+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
38268+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
38269+ FOREIGN KEY (accepted) REFERENCES subscription(pk) ON DELETE CASCADE,
38270+ UNIQUE (list, address) ON CONFLICT ROLLBACK
38271+ );
38272+
38273+ CREATE TABLE IF NOT EXISTS post (
38274+ pk INTEGER PRIMARY KEY NOT NULL,
38275+ list INTEGER NOT NULL,
38276+ envelope_from TEXT,
38277+ address TEXT NOT NULL,
38278+ message_id TEXT NOT NULL,
38279+ message BLOB NOT NULL,
38280+ headers_json TEXT,
38281+ timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
38282+ datetime TEXT NOT NULL DEFAULT (datetime()),
38283+ created INTEGER NOT NULL DEFAULT (unixepoch())
38284+ );
38285+
38286+ CREATE TABLE IF NOT EXISTS template (
38287+ pk INTEGER PRIMARY KEY NOT NULL,
38288+ name TEXT NOT NULL,
38289+ list INTEGER,
38290+ subject TEXT,
38291+ headers_json TEXT,
38292+ body TEXT NOT NULL,
38293+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38294+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
38295+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
38296+ UNIQUE (list, name) ON CONFLICT ROLLBACK
38297+ );
38298+
38299+ CREATE TABLE IF NOT EXISTS settings_json_schema (
38300+ pk INTEGER PRIMARY KEY NOT NULL,
38301+ id TEXT NOT NULL UNIQUE,
38302+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
38303+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38304+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
38305+ );
38306+
38307+ CREATE TABLE IF NOT EXISTS list_settings_json (
38308+ pk INTEGER PRIMARY KEY NOT NULL,
38309+ name TEXT NOT NULL,
38310+ list INTEGER,
38311+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
38312+ is_valid BOOLEAN_TYPE(is_valid) DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
38313+ created INTEGER NOT NULL DEFAULT (unixepoch()),
38314+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
38315+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
38316+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
38317+ UNIQUE (list, name) ON CONFLICT ROLLBACK
38318+ );
38319+
38320+ CREATE TRIGGER
38321+ IF NOT EXISTS is_valid_settings_json_on_update
38322+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
38323+ FOR EACH ROW
38324+ BEGIN
38325+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
38326+ UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
38327+ END;
38328+
38329+ CREATE TRIGGER
38330+ IF NOT EXISTS is_valid_settings_json_on_insert
38331+ AFTER INSERT ON list_settings_json
38332+ FOR EACH ROW
38333+ BEGIN
38334+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
38335+ UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
38336+ END;
38337+
38338+ CREATE TRIGGER
38339+ IF NOT EXISTS invalidate_settings_json_on_schema_update
38340+ AFTER UPDATE OF value, id ON settings_json_schema
38341+ FOR EACH ROW
38342+ BEGIN
38343+ UPDATE list_settings_json SET name = NEW.id, is_valid = BOOLEAN_FALSE() WHERE name = OLD.id;
38344+ END;
38345+
38346+ -- # Queues
38347+ --
38348+ -- ## The "maildrop" queue
38349+ --
38350+ -- Messages that have been submitted but not yet processed, await processing
38351+ -- in the "maildrop" queue. Messages can be added to the "maildrop" queue
38352+ -- even when mailpot is not running.
38353+ --
38354+ -- ## The "deferred" queue
38355+ --
38356+ -- When all the deliverable recipients for a message are delivered, and for
38357+ -- some recipients delivery failed for a transient reason (it might succeed
38358+ -- later), the message is placed in the "deferred" queue.
38359+ --
38360+ -- ## The "hold" queue
38361+ --
38362+ -- List administrators may introduce rules for emails to be placed
38363+ -- indefinitely in the "hold" queue. Messages placed in the "hold" queue stay
38364+ -- there until the administrator intervenes. No periodic delivery attempts
38365+ -- are made for messages in the "hold" queue.
38366+
38367+ -- ## The "out" queue
38368+ --
38369+ -- Emails that must be sent as soon as possible.
38370+ CREATE TABLE IF NOT EXISTS queue (
38371+ pk INTEGER PRIMARY KEY NOT NULL,
38372+ which TEXT
38373+ CHECK (
38374+ which IN
38375+ ('maildrop',
38376+ 'hold',
38377+ 'deferred',
38378+ 'corrupt',
38379+ 'error',
38380+ 'out')
38381+ ) NOT NULL,
38382+ list INTEGER,
38383+ comment TEXT,
38384+ to_addresses TEXT NOT NULL,
38385+ from_address TEXT NOT NULL,
38386+ subject TEXT NOT NULL,
38387+ message_id TEXT NOT NULL,
38388+ message BLOB NOT NULL,
38389+ timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
38390+ datetime TEXT NOT NULL DEFAULT (datetime()),
38391+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
38392+ UNIQUE (to_addresses, message_id) ON CONFLICT ROLLBACK
38393+ );
38394+
38395+ CREATE TABLE IF NOT EXISTS bounce (
38396+ pk INTEGER PRIMARY KEY NOT NULL,
38397+ subscription INTEGER NOT NULL UNIQUE,
38398+ count INTEGER NOT NULL DEFAULT 0,
38399+ last_bounce TEXT NOT NULL DEFAULT (datetime()),
38400+ FOREIGN KEY (subscription) REFERENCES subscription(pk) ON DELETE CASCADE
38401+ );
38402+
38403+ CREATE INDEX IF NOT EXISTS post_listpk_idx ON post(list);
38404+ CREATE INDEX IF NOT EXISTS post_msgid_idx ON post(message_id);
38405+ CREATE INDEX IF NOT EXISTS list_idx ON list(id);
38406+ CREATE INDEX IF NOT EXISTS subscription_idx ON subscription(address);
38407+
38408+ -- TAG(accept_candidate): Update candidacy with 'subscription' foreign key on
38409+ -- 'subscription' insert.
38410+ CREATE TRIGGER IF NOT EXISTS accept_candidate AFTER INSERT ON subscription
38411+ FOR EACH ROW
38412+ BEGIN
38413+ UPDATE candidate_subscription SET accepted = NEW.pk, last_modified = unixepoch()
38414+ WHERE candidate_subscription.list = NEW.list AND candidate_subscription.address = NEW.address;
38415+ END;
38416+
38417+ -- TAG(verify_subscription_email): If list settings require e-mail to be
38418+ -- verified, update new subscription's 'verify' column value.
38419+ CREATE TRIGGER IF NOT EXISTS verify_subscription_email AFTER INSERT ON subscription
38420+ FOR EACH ROW
38421+ BEGIN
38422+ UPDATE subscription
38423+ SET verified = BOOLEAN_FALSE(), last_modified = unixepoch()
38424+ WHERE
38425+ subscription.pk = NEW.pk
38426+ AND
38427+ EXISTS
38428+ (SELECT 1 FROM list WHERE pk = NEW.list AND verify = BOOLEAN_TRUE());
38429+ END;
38430+
38431+ -- TAG(add_account): Update list subscription entries with 'account' foreign
38432+ -- key, if addresses match.
38433+ CREATE TRIGGER IF NOT EXISTS add_account AFTER INSERT ON account
38434+ FOR EACH ROW
38435+ BEGIN
38436+ UPDATE subscription SET account = NEW.pk, last_modified = unixepoch()
38437+ WHERE subscription.address = NEW.address;
38438+ END;
38439+
38440+ -- TAG(add_account_to_subscription): When adding a new 'subscription', auto
38441+ -- set 'account' value if there already exists an 'account' entry with the
38442+ -- same address.
38443+ CREATE TRIGGER IF NOT EXISTS add_account_to_subscription
38444+ AFTER INSERT ON subscription
38445+ FOR EACH ROW
38446+ WHEN
38447+ NEW.account IS NULL
38448+ AND EXISTS (SELECT 1 FROM account WHERE address = NEW.address)
38449+ BEGIN
38450+ UPDATE subscription
38451+ SET account = (SELECT pk FROM account WHERE address = NEW.address),
38452+ last_modified = unixepoch()
38453+ WHERE subscription.pk = NEW.pk;
38454+ END;
38455+
38456+ update_last_modified(`list')
38457+ update_last_modified(`owner')
38458+ update_last_modified(`post_policy')
38459+ update_last_modified(`subscription_policy')
38460+ update_last_modified(`subscription')
38461+ update_last_modified(`account')
38462+ update_last_modified(`candidate_subscription')
38463+ update_last_modified(`template')
38464+ update_last_modified(`settings_json_schema')
38465+ update_last_modified(`list_settings_json')
38466+
38467+ CREATE TRIGGER
38468+ IF NOT EXISTS sort_topics_update_trigger
38469+ AFTER UPDATE ON list
38470+ FOR EACH ROW
38471+ WHEN NEW.topics != OLD.topics
38472+ BEGIN
38473+ UPDATE list SET topics = ord.arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
38474+ END;
38475+
38476+ CREATE TRIGGER
38477+ IF NOT EXISTS sort_topics_new_trigger
38478+ AFTER INSERT ON list
38479+ FOR EACH ROW
38480+ BEGIN
38481+ UPDATE list SET topics = arr FROM (SELECT json_group_array(ord.val) AS arr, ord.pk AS pk FROM (SELECT json_each.value AS val, list.pk AS pk FROM list, json_each(list.topics) ORDER BY val ASC) AS ord GROUP BY pk) AS ord WHERE ord.pk = list.pk AND list.pk = NEW.pk;
38482+ END;
38483 diff --git a/mailpot/src/submission.rs b/mailpot/src/submission.rs
38484new file mode 100644
38485index 0000000..6a3ca9a
38486--- /dev/null
38487+++ b/mailpot/src/submission.rs
38488 @@ -0,0 +1,73 @@
38489+ /*
38490+ * This file is part of mailpot
38491+ *
38492+ * Copyright 2020 - Manos Pitsidianakis
38493+ *
38494+ * This program is free software: you can redistribute it and/or modify
38495+ * it under the terms of the GNU Affero General Public License as
38496+ * published by the Free Software Foundation, either version 3 of the
38497+ * License, or (at your option) any later version.
38498+ *
38499+ * This program is distributed in the hope that it will be useful,
38500+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
38501+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
38502+ * GNU Affero General Public License for more details.
38503+ *
38504+ * You should have received a copy of the GNU Affero General Public License
38505+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
38506+ */
38507+
38508+ //! Submit e-mail through SMTP.
38509+
38510+ use std::{future::Future, pin::Pin};
38511+
38512+ use melib::smtp::*;
38513+
38514+ use crate::{errors::*, queue::QueueEntry, Connection};
38515+
38516+ type ResultFuture<T> = Result<Pin<Box<dyn Future<Output = Result<T>> + Send + 'static>>>;
38517+
38518+ impl Connection {
38519+ /// Return an SMTP connection handle if the database connection has one
38520+ /// configured.
38521+ pub fn new_smtp_connection(&self) -> ResultFuture<SmtpConnection> {
38522+ if let crate::SendMail::Smtp(ref smtp_conf) = &self.conf().send_mail {
38523+ let smtp_conf = smtp_conf.clone();
38524+ Ok(Box::pin(async move {
38525+ Ok(SmtpConnection::new_connection(smtp_conf).await?)
38526+ }))
38527+ } else {
38528+ Err("No SMTP configuration found: use the shell command instead.".into())
38529+ }
38530+ }
38531+
38532+ /// Submit queue items from `values` to their recipients.
38533+ pub async fn submit(
38534+ smtp_connection: &mut melib::smtp::SmtpConnection,
38535+ message: &QueueEntry,
38536+ dry_run: bool,
38537+ ) -> Result<()> {
38538+ let QueueEntry {
38539+ ref comment,
38540+ ref to_addresses,
38541+ ref from_address,
38542+ ref subject,
38543+ ref message,
38544+ ..
38545+ } = message;
38546+ log::info!(
38547+ "Sending message from {from_address} to {to_addresses} with subject {subject:?} and \
38548+ comment {comment:?}",
38549+ );
38550+ let recipients = melib::Address::list_try_from(to_addresses)
38551+ .context(format!("Could not parse {to_addresses:?}"))?;
38552+ if dry_run {
38553+ log::warn!("Dry run is true, not actually submitting anything to SMTP server.");
38554+ } else {
38555+ smtp_connection
38556+ .mail_transaction(&String::from_utf8_lossy(message), Some(&recipients))
38557+ .await?;
38558+ }
38559+ Ok(())
38560+ }
38561+ }
38562 diff --git a/mailpot/src/subscriptions.rs b/mailpot/src/subscriptions.rs
38563new file mode 100644
38564index 0000000..cb6edbf
38565--- /dev/null
38566+++ b/mailpot/src/subscriptions.rs
38567 @@ -0,0 +1,815 @@
38568+ /*
38569+ * This file is part of mailpot
38570+ *
38571+ * Copyright 2020 - Manos Pitsidianakis
38572+ *
38573+ * This program is free software: you can redistribute it and/or modify
38574+ * it under the terms of the GNU Affero General Public License as
38575+ * published by the Free Software Foundation, either version 3 of the
38576+ * License, or (at your option) any later version.
38577+ *
38578+ * This program is distributed in the hope that it will be useful,
38579+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
38580+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
38581+ * GNU Affero General Public License for more details.
38582+ *
38583+ * You should have received a copy of the GNU Affero General Public License
38584+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
38585+ */
38586+
38587+ //! User subscriptions.
38588+
38589+ use log::trace;
38590+ use rusqlite::OptionalExtension;
38591+
38592+ use crate::{
38593+ errors::{ErrorKind::*, *},
38594+ models::{
38595+ changesets::{AccountChangeset, ListSubscriptionChangeset},
38596+ Account, ListCandidateSubscription, ListSubscription,
38597+ },
38598+ Connection, DbVal,
38599+ };
38600+
38601+ impl Connection {
38602+ /// Fetch all subscriptions of a mailing list.
38603+ pub fn list_subscriptions(&self, list_pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
38604+ let mut stmt = self
38605+ .connection
38606+ .prepare("SELECT * FROM subscription WHERE list = ?;")?;
38607+ let list_iter = stmt.query_map([&list_pk], |row| {
38608+ let pk = row.get("pk")?;
38609+ Ok(DbVal(
38610+ ListSubscription {
38611+ pk: row.get("pk")?,
38612+ list: row.get("list")?,
38613+ address: row.get("address")?,
38614+ account: row.get("account")?,
38615+ name: row.get("name")?,
38616+ digest: row.get("digest")?,
38617+ enabled: row.get("enabled")?,
38618+ verified: row.get("verified")?,
38619+ hide_address: row.get("hide_address")?,
38620+ receive_duplicates: row.get("receive_duplicates")?,
38621+ receive_own_posts: row.get("receive_own_posts")?,
38622+ receive_confirmation: row.get("receive_confirmation")?,
38623+ },
38624+ pk,
38625+ ))
38626+ })?;
38627+
38628+ let mut ret = vec![];
38629+ for list in list_iter {
38630+ let list = list?;
38631+ ret.push(list);
38632+ }
38633+ Ok(ret)
38634+ }
38635+
38636+ /// Fetch mailing list subscription.
38637+ pub fn list_subscription(&self, list_pk: i64, pk: i64) -> Result<DbVal<ListSubscription>> {
38638+ let mut stmt = self
38639+ .connection
38640+ .prepare("SELECT * FROM subscription WHERE list = ? AND pk = ?;")?;
38641+
38642+ let ret = stmt.query_row([&list_pk, &pk], |row| {
38643+ let _pk: i64 = row.get("pk")?;
38644+ debug_assert_eq!(pk, _pk);
38645+ Ok(DbVal(
38646+ ListSubscription {
38647+ pk,
38648+ list: row.get("list")?,
38649+ address: row.get("address")?,
38650+ account: row.get("account")?,
38651+ name: row.get("name")?,
38652+ digest: row.get("digest")?,
38653+ enabled: row.get("enabled")?,
38654+ verified: row.get("verified")?,
38655+ hide_address: row.get("hide_address")?,
38656+ receive_duplicates: row.get("receive_duplicates")?,
38657+ receive_own_posts: row.get("receive_own_posts")?,
38658+ receive_confirmation: row.get("receive_confirmation")?,
38659+ },
38660+ pk,
38661+ ))
38662+ })?;
38663+ Ok(ret)
38664+ }
38665+
38666+ /// Fetch mailing list subscription by their address.
38667+ pub fn list_subscription_by_address(
38668+ &self,
38669+ list_pk: i64,
38670+ address: &str,
38671+ ) -> Result<DbVal<ListSubscription>> {
38672+ let mut stmt = self
38673+ .connection
38674+ .prepare("SELECT * FROM subscription WHERE list = ? AND address = ?;")?;
38675+
38676+ let ret = stmt.query_row(rusqlite::params![&list_pk, &address], |row| {
38677+ let pk = row.get("pk")?;
38678+ let address_ = row.get("address")?;
38679+ debug_assert_eq!(address, &address_);
38680+ Ok(DbVal(
38681+ ListSubscription {
38682+ pk,
38683+ list: row.get("list")?,
38684+ address: address_,
38685+ account: row.get("account")?,
38686+ name: row.get("name")?,
38687+ digest: row.get("digest")?,
38688+ enabled: row.get("enabled")?,
38689+ verified: row.get("verified")?,
38690+ hide_address: row.get("hide_address")?,
38691+ receive_duplicates: row.get("receive_duplicates")?,
38692+ receive_own_posts: row.get("receive_own_posts")?,
38693+ receive_confirmation: row.get("receive_confirmation")?,
38694+ },
38695+ pk,
38696+ ))
38697+ })?;
38698+ Ok(ret)
38699+ }
38700+
38701+ /// Add subscription to mailing list.
38702+ pub fn add_subscription(
38703+ &self,
38704+ list_pk: i64,
38705+ mut new_val: ListSubscription,
38706+ ) -> Result<DbVal<ListSubscription>> {
38707+ new_val.list = list_pk;
38708+ let mut stmt = self
38709+ .connection
38710+ .prepare(
38711+ "INSERT INTO subscription(list, address, account, name, enabled, digest, \
38712+ verified, hide_address, receive_duplicates, receive_own_posts, \
38713+ receive_confirmation) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *;",
38714+ )
38715+ .unwrap();
38716+ let val = stmt.query_row(
38717+ rusqlite::params![
38718+ &new_val.list,
38719+ &new_val.address,
38720+ &new_val.account,
38721+ &new_val.name,
38722+ &new_val.enabled,
38723+ &new_val.digest,
38724+ &new_val.verified,
38725+ &new_val.hide_address,
38726+ &new_val.receive_duplicates,
38727+ &new_val.receive_own_posts,
38728+ &new_val.receive_confirmation
38729+ ],
38730+ |row| {
38731+ let pk = row.get("pk")?;
38732+ Ok(DbVal(
38733+ ListSubscription {
38734+ pk,
38735+ list: row.get("list")?,
38736+ address: row.get("address")?,
38737+ name: row.get("name")?,
38738+ account: row.get("account")?,
38739+ digest: row.get("digest")?,
38740+ enabled: row.get("enabled")?,
38741+ verified: row.get("verified")?,
38742+ hide_address: row.get("hide_address")?,
38743+ receive_duplicates: row.get("receive_duplicates")?,
38744+ receive_own_posts: row.get("receive_own_posts")?,
38745+ receive_confirmation: row.get("receive_confirmation")?,
38746+ },
38747+ pk,
38748+ ))
38749+ },
38750+ )?;
38751+ trace!("add_subscription {:?}.", &val);
38752+ // table entry might be modified by triggers, so don't rely on RETURNING value.
38753+ self.list_subscription(list_pk, val.pk())
38754+ }
38755+
38756+ /// Fetch all candidate subscriptions of a mailing list.
38757+ pub fn list_subscription_requests(
38758+ &self,
38759+ list_pk: i64,
38760+ ) -> Result<Vec<DbVal<ListCandidateSubscription>>> {
38761+ let mut stmt = self
38762+ .connection
38763+ .prepare("SELECT * FROM candidate_subscription WHERE list = ?;")?;
38764+ let list_iter = stmt.query_map([&list_pk], |row| {
38765+ let pk = row.get("pk")?;
38766+ Ok(DbVal(
38767+ ListCandidateSubscription {
38768+ pk: row.get("pk")?,
38769+ list: row.get("list")?,
38770+ address: row.get("address")?,
38771+ name: row.get("name")?,
38772+ accepted: row.get("accepted")?,
38773+ },
38774+ pk,
38775+ ))
38776+ })?;
38777+
38778+ let mut ret = vec![];
38779+ for list in list_iter {
38780+ let list = list?;
38781+ ret.push(list);
38782+ }
38783+ Ok(ret)
38784+ }
38785+
38786+ /// Create subscription candidate.
38787+ pub fn add_candidate_subscription(
38788+ &self,
38789+ list_pk: i64,
38790+ mut new_val: ListSubscription,
38791+ ) -> Result<DbVal<ListCandidateSubscription>> {
38792+ new_val.list = list_pk;
38793+ let mut stmt = self.connection.prepare(
38794+ "INSERT INTO candidate_subscription(list, address, name, accepted) VALUES(?, ?, ?, ?) \
38795+ RETURNING *;",
38796+ )?;
38797+ let val = stmt.query_row(
38798+ rusqlite::params![&new_val.list, &new_val.address, &new_val.name, None::<i64>,],
38799+ |row| {
38800+ let pk = row.get("pk")?;
38801+ Ok(DbVal(
38802+ ListCandidateSubscription {
38803+ pk,
38804+ list: row.get("list")?,
38805+ address: row.get("address")?,
38806+ name: row.get("name")?,
38807+ accepted: row.get("accepted")?,
38808+ },
38809+ pk,
38810+ ))
38811+ },
38812+ )?;
38813+ drop(stmt);
38814+
38815+ trace!("add_candidate_subscription {:?}.", &val);
38816+ // table entry might be modified by triggers, so don't rely on RETURNING value.
38817+ self.candidate_subscription(val.pk())
38818+ }
38819+
38820+ /// Fetch subscription candidate by primary key.
38821+ pub fn candidate_subscription(&self, pk: i64) -> Result<DbVal<ListCandidateSubscription>> {
38822+ let mut stmt = self
38823+ .connection
38824+ .prepare("SELECT * FROM candidate_subscription WHERE pk = ?;")?;
38825+ let val = stmt
38826+ .query_row(rusqlite::params![&pk], |row| {
38827+ let _pk: i64 = row.get("pk")?;
38828+ debug_assert_eq!(pk, _pk);
38829+ Ok(DbVal(
38830+ ListCandidateSubscription {
38831+ pk,
38832+ list: row.get("list")?,
38833+ address: row.get("address")?,
38834+ name: row.get("name")?,
38835+ accepted: row.get("accepted")?,
38836+ },
38837+ pk,
38838+ ))
38839+ })
38840+ .map_err(|err| {
38841+ if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
38842+ Error::from(err)
38843+ .chain_err(|| NotFound("Candidate subscription with this pk not found!"))
38844+ } else {
38845+ err.into()
38846+ }
38847+ })?;
38848+
38849+ Ok(val)
38850+ }
38851+
38852+ /// Accept subscription candidate.
38853+ pub fn accept_candidate_subscription(&self, pk: i64) -> Result<DbVal<ListSubscription>> {
38854+ let val = self.connection.query_row(
38855+ "INSERT INTO subscription(list, address, name, enabled, digest, verified, \
38856+ hide_address, receive_duplicates, receive_own_posts, receive_confirmation) SELECT \
38857+ list, address, name, 1, 0, 0, 0, 1, 1, 0 FROM candidate_subscription WHERE pk = ? \
38858+ RETURNING *;",
38859+ rusqlite::params![&pk],
38860+ |row| {
38861+ let pk = row.get("pk")?;
38862+ Ok(DbVal(
38863+ ListSubscription {
38864+ pk,
38865+ list: row.get("list")?,
38866+ address: row.get("address")?,
38867+ account: row.get("account")?,
38868+ name: row.get("name")?,
38869+ digest: row.get("digest")?,
38870+ enabled: row.get("enabled")?,
38871+ verified: row.get("verified")?,
38872+ hide_address: row.get("hide_address")?,
38873+ receive_duplicates: row.get("receive_duplicates")?,
38874+ receive_own_posts: row.get("receive_own_posts")?,
38875+ receive_confirmation: row.get("receive_confirmation")?,
38876+ },
38877+ pk,
38878+ ))
38879+ },
38880+ )?;
38881+
38882+ trace!("accept_candidate_subscription {:?}.", &val);
38883+ // table entry might be modified by triggers, so don't rely on RETURNING value.
38884+ let ret = self.list_subscription(val.list, val.pk())?;
38885+
38886+ // assert that [ref:accept_candidate] trigger works.
38887+ debug_assert_eq!(Some(ret.pk), self.candidate_subscription(pk)?.accepted);
38888+ Ok(ret)
38889+ }
38890+
38891+ /// Remove a subscription by their address.
38892+ pub fn remove_subscription(&self, list_pk: i64, address: &str) -> Result<()> {
38893+ self.connection
38894+ .query_row(
38895+ "DELETE FROM subscription WHERE list = ? AND address = ? RETURNING *;",
38896+ rusqlite::params![&list_pk, &address],
38897+ |_| Ok(()),
38898+ )
38899+ .map_err(|err| {
38900+ if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
38901+ Error::from(err).chain_err(|| NotFound("list or list owner not found!"))
38902+ } else {
38903+ err.into()
38904+ }
38905+ })?;
38906+
38907+ Ok(())
38908+ }
38909+
38910+ /// Update a mailing list subscription.
38911+ pub fn update_subscription(&self, change_set: ListSubscriptionChangeset) -> Result<()> {
38912+ let pk = self
38913+ .list_subscription_by_address(change_set.list, &change_set.address)?
38914+ .pk;
38915+ if matches!(
38916+ change_set,
38917+ ListSubscriptionChangeset {
38918+ list: _,
38919+ address: _,
38920+ account: None,
38921+ name: None,
38922+ digest: None,
38923+ verified: None,
38924+ hide_address: None,
38925+ receive_duplicates: None,
38926+ receive_own_posts: None,
38927+ receive_confirmation: None,
38928+ enabled: None,
38929+ }
38930+ ) {
38931+ return Ok(());
38932+ }
38933+
38934+ let ListSubscriptionChangeset {
38935+ list,
38936+ address: _,
38937+ name,
38938+ account,
38939+ digest,
38940+ enabled,
38941+ verified,
38942+ hide_address,
38943+ receive_duplicates,
38944+ receive_own_posts,
38945+ receive_confirmation,
38946+ } = change_set;
38947+ let tx = self.savepoint(Some(stringify!(update_subscription)))?;
38948+
38949+ macro_rules! update {
38950+ ($field:tt) => {{
38951+ if let Some($field) = $field {
38952+ tx.connection.execute(
38953+ concat!(
38954+ "UPDATE subscription SET ",
38955+ stringify!($field),
38956+ " = ? WHERE list = ? AND pk = ?;"
38957+ ),
38958+ rusqlite::params![&$field, &list, &pk],
38959+ )?;
38960+ }
38961+ }};
38962+ }
38963+ update!(name);
38964+ update!(account);
38965+ update!(digest);
38966+ update!(enabled);
38967+ update!(verified);
38968+ update!(hide_address);
38969+ update!(receive_duplicates);
38970+ update!(receive_own_posts);
38971+ update!(receive_confirmation);
38972+
38973+ tx.commit()?;
38974+ Ok(())
38975+ }
38976+
38977+ /// Fetch account by pk.
38978+ pub fn account(&self, pk: i64) -> Result<Option<DbVal<Account>>> {
38979+ let mut stmt = self
38980+ .connection
38981+ .prepare("SELECT * FROM account WHERE pk = ?;")?;
38982+
38983+ let ret = stmt
38984+ .query_row(rusqlite::params![&pk], |row| {
38985+ let _pk: i64 = row.get("pk")?;
38986+ debug_assert_eq!(pk, _pk);
38987+ Ok(DbVal(
38988+ Account {
38989+ pk,
38990+ name: row.get("name")?,
38991+ address: row.get("address")?,
38992+ public_key: row.get("public_key")?,
38993+ password: row.get("password")?,
38994+ enabled: row.get("enabled")?,
38995+ },
38996+ pk,
38997+ ))
38998+ })
38999+ .optional()?;
39000+ Ok(ret)
39001+ }
39002+
39003+ /// Fetch account by address.
39004+ pub fn account_by_address(&self, address: &str) -> Result<Option<DbVal<Account>>> {
39005+ let mut stmt = self
39006+ .connection
39007+ .prepare("SELECT * FROM account WHERE address = ?;")?;
39008+
39009+ let ret = stmt
39010+ .query_row(rusqlite::params![&address], |row| {
39011+ let pk = row.get("pk")?;
39012+ Ok(DbVal(
39013+ Account {
39014+ pk,
39015+ name: row.get("name")?,
39016+ address: row.get("address")?,
39017+ public_key: row.get("public_key")?,
39018+ password: row.get("password")?,
39019+ enabled: row.get("enabled")?,
39020+ },
39021+ pk,
39022+ ))
39023+ })
39024+ .optional()?;
39025+ Ok(ret)
39026+ }
39027+
39028+ /// Fetch all subscriptions of an account by primary key.
39029+ pub fn account_subscriptions(&self, pk: i64) -> Result<Vec<DbVal<ListSubscription>>> {
39030+ let mut stmt = self
39031+ .connection
39032+ .prepare("SELECT * FROM subscription WHERE account = ?;")?;
39033+ let list_iter = stmt.query_map([&pk], |row| {
39034+ let pk = row.get("pk")?;
39035+ Ok(DbVal(
39036+ ListSubscription {
39037+ pk: row.get("pk")?,
39038+ list: row.get("list")?,
39039+ address: row.get("address")?,
39040+ account: row.get("account")?,
39041+ name: row.get("name")?,
39042+ digest: row.get("digest")?,
39043+ enabled: row.get("enabled")?,
39044+ verified: row.get("verified")?,
39045+ hide_address: row.get("hide_address")?,
39046+ receive_duplicates: row.get("receive_duplicates")?,
39047+ receive_own_posts: row.get("receive_own_posts")?,
39048+ receive_confirmation: row.get("receive_confirmation")?,
39049+ },
39050+ pk,
39051+ ))
39052+ })?;
39053+
39054+ let mut ret = vec![];
39055+ for list in list_iter {
39056+ let list = list?;
39057+ ret.push(list);
39058+ }
39059+ Ok(ret)
39060+ }
39061+
39062+ /// Fetch all accounts.
39063+ pub fn accounts(&self) -> Result<Vec<DbVal<Account>>> {
39064+ let mut stmt = self
39065+ .connection
39066+ .prepare("SELECT * FROM account ORDER BY pk ASC;")?;
39067+ let list_iter = stmt.query_map([], |row| {
39068+ let pk = row.get("pk")?;
39069+ Ok(DbVal(
39070+ Account {
39071+ pk,
39072+ name: row.get("name")?,
39073+ address: row.get("address")?,
39074+ public_key: row.get("public_key")?,
39075+ password: row.get("password")?,
39076+ enabled: row.get("enabled")?,
39077+ },
39078+ pk,
39079+ ))
39080+ })?;
39081+
39082+ let mut ret = vec![];
39083+ for list in list_iter {
39084+ let list = list?;
39085+ ret.push(list);
39086+ }
39087+ Ok(ret)
39088+ }
39089+
39090+ /// Add account.
39091+ pub fn add_account(&self, new_val: Account) -> Result<DbVal<Account>> {
39092+ let mut stmt = self
39093+ .connection
39094+ .prepare(
39095+ "INSERT INTO account(name, address, public_key, password, enabled) VALUES(?, ?, \
39096+ ?, ?, ?) RETURNING *;",
39097+ )
39098+ .unwrap();
39099+ let ret = stmt.query_row(
39100+ rusqlite::params![
39101+ &new_val.name,
39102+ &new_val.address,
39103+ &new_val.public_key,
39104+ &new_val.password,
39105+ &new_val.enabled,
39106+ ],
39107+ |row| {
39108+ let pk = row.get("pk")?;
39109+ Ok(DbVal(
39110+ Account {
39111+ pk,
39112+ name: row.get("name")?,
39113+ address: row.get("address")?,
39114+ public_key: row.get("public_key")?,
39115+ password: row.get("password")?,
39116+ enabled: row.get("enabled")?,
39117+ },
39118+ pk,
39119+ ))
39120+ },
39121+ )?;
39122+
39123+ trace!("add_account {:?}.", &ret);
39124+ Ok(ret)
39125+ }
39126+
39127+ /// Remove an account by their address.
39128+ pub fn remove_account(&self, address: &str) -> Result<()> {
39129+ self.connection
39130+ .query_row(
39131+ "DELETE FROM account WHERE address = ? RETURNING *;",
39132+ rusqlite::params![&address],
39133+ |_| Ok(()),
39134+ )
39135+ .map_err(|err| {
39136+ if matches!(err, rusqlite::Error::QueryReturnedNoRows) {
39137+ Error::from(err).chain_err(|| NotFound("account not found!"))
39138+ } else {
39139+ err.into()
39140+ }
39141+ })?;
39142+
39143+ Ok(())
39144+ }
39145+
39146+ /// Update an account.
39147+ pub fn update_account(&self, change_set: AccountChangeset) -> Result<()> {
39148+ let Some(acc) = self.account_by_address(&change_set.address)? else {
39149+ return Err(NotFound("account with this address not found!").into());
39150+ };
39151+ let pk = acc.pk;
39152+ if matches!(
39153+ change_set,
39154+ AccountChangeset {
39155+ address: _,
39156+ name: None,
39157+ public_key: None,
39158+ password: None,
39159+ enabled: None,
39160+ }
39161+ ) {
39162+ return Ok(());
39163+ }
39164+
39165+ let AccountChangeset {
39166+ address: _,
39167+ name,
39168+ public_key,
39169+ password,
39170+ enabled,
39171+ } = change_set;
39172+ let tx = self.savepoint(Some(stringify!(update_account)))?;
39173+
39174+ macro_rules! update {
39175+ ($field:tt) => {{
39176+ if let Some($field) = $field {
39177+ tx.connection.execute(
39178+ concat!(
39179+ "UPDATE account SET ",
39180+ stringify!($field),
39181+ " = ? WHERE pk = ?;"
39182+ ),
39183+ rusqlite::params![&$field, &pk],
39184+ )?;
39185+ }
39186+ }};
39187+ }
39188+ update!(name);
39189+ update!(public_key);
39190+ update!(password);
39191+ update!(enabled);
39192+
39193+ tx.commit()?;
39194+ Ok(())
39195+ }
39196+ }
39197+
39198+ #[cfg(test)]
39199+ mod tests {
39200+ use super::*;
39201+ use crate::*;
39202+
39203+ #[test]
39204+ fn test_subscription_ops() {
39205+ use tempfile::TempDir;
39206+
39207+ let tmp_dir = TempDir::new().unwrap();
39208+ let db_path = tmp_dir.path().join("mpot.db");
39209+ let config = Configuration {
39210+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
39211+ db_path,
39212+ data_path: tmp_dir.path().to_path_buf(),
39213+ administrators: vec![],
39214+ };
39215+
39216+ let db = Connection::open_or_create_db(config).unwrap().trusted();
39217+ let list = db
39218+ .create_list(MailingList {
39219+ pk: -1,
39220+ name: "foobar chat".into(),
39221+ id: "foo-chat".into(),
39222+ address: "foo-chat@example.com".into(),
39223+ topics: vec![],
39224+ description: None,
39225+ archive_url: None,
39226+ })
39227+ .unwrap();
39228+ let secondary_list = db
39229+ .create_list(MailingList {
39230+ pk: -1,
39231+ name: "foobar chat2".into(),
39232+ id: "foo-chat2".into(),
39233+ address: "foo-chat2@example.com".into(),
39234+ topics: vec![],
39235+ description: None,
39236+ archive_url: None,
39237+ })
39238+ .unwrap();
39239+ for i in 0..4 {
39240+ let sub = db
39241+ .add_subscription(
39242+ list.pk(),
39243+ ListSubscription {
39244+ pk: -1,
39245+ list: list.pk(),
39246+ address: format!("{i}@example.com"),
39247+ account: None,
39248+ name: Some(format!("User{i}")),
39249+ digest: false,
39250+ hide_address: false,
39251+ receive_duplicates: false,
39252+ receive_own_posts: false,
39253+ receive_confirmation: false,
39254+ enabled: true,
39255+ verified: false,
39256+ },
39257+ )
39258+ .unwrap();
39259+ assert_eq!(db.list_subscription(list.pk(), sub.pk()).unwrap(), sub);
39260+ assert_eq!(
39261+ db.list_subscription_by_address(list.pk(), &sub.address)
39262+ .unwrap(),
39263+ sub
39264+ );
39265+ }
39266+
39267+ assert_eq!(db.accounts().unwrap(), vec![]);
39268+ assert_eq!(
39269+ db.remove_subscription(list.pk(), "nonexistent@example.com")
39270+ .map_err(|err| err.to_string())
39271+ .unwrap_err(),
39272+ NotFound("list or list owner not found!").to_string()
39273+ );
39274+
39275+ let cand = db
39276+ .add_candidate_subscription(
39277+ list.pk(),
39278+ ListSubscription {
39279+ pk: -1,
39280+ list: list.pk(),
39281+ address: "4@example.com".into(),
39282+ account: None,
39283+ name: Some("User4".into()),
39284+ digest: false,
39285+ hide_address: false,
39286+ receive_duplicates: false,
39287+ receive_own_posts: false,
39288+ receive_confirmation: false,
39289+ enabled: true,
39290+ verified: false,
39291+ },
39292+ )
39293+ .unwrap();
39294+ let accepted = db.accept_candidate_subscription(cand.pk()).unwrap();
39295+
39296+ assert_eq!(db.account(5).unwrap(), None);
39297+ assert_eq!(
39298+ db.remove_account("4@example.com")
39299+ .map_err(|err| err.to_string())
39300+ .unwrap_err(),
39301+ NotFound("account not found!").to_string()
39302+ );
39303+
39304+ let acc = db
39305+ .add_account(Account {
39306+ pk: -1,
39307+ name: accepted.name.clone(),
39308+ address: accepted.address.clone(),
39309+ public_key: None,
39310+ password: String::new(),
39311+ enabled: true,
39312+ })
39313+ .unwrap();
39314+
39315+ // Test [ref:add_account] SQL trigger (see schema.sql)
39316+ assert_eq!(
39317+ db.list_subscription(list.pk(), accepted.pk())
39318+ .unwrap()
39319+ .account,
39320+ Some(acc.pk())
39321+ );
39322+ // Test [ref:add_account_to_subscription] SQL trigger (see schema.sql)
39323+ let sub = db
39324+ .add_subscription(
39325+ secondary_list.pk(),
39326+ ListSubscription {
39327+ pk: -1,
39328+ list: secondary_list.pk(),
39329+ address: "4@example.com".into(),
39330+ account: None,
39331+ name: Some("User4".into()),
39332+ digest: false,
39333+ hide_address: false,
39334+ receive_duplicates: false,
39335+ receive_own_posts: false,
39336+ receive_confirmation: false,
39337+ enabled: true,
39338+ verified: true,
39339+ },
39340+ )
39341+ .unwrap();
39342+ assert_eq!(sub.account, Some(acc.pk()));
39343+ // Test [ref:verify_subscription_email] SQL trigger (see schema.sql)
39344+ assert!(!sub.verified);
39345+
39346+ assert_eq!(db.accounts().unwrap(), vec![acc.clone()]);
39347+
39348+ assert_eq!(
39349+ db.update_account(AccountChangeset {
39350+ address: "nonexistent@example.com".into(),
39351+ ..AccountChangeset::default()
39352+ })
39353+ .map_err(|err| err.to_string())
39354+ .unwrap_err(),
39355+ NotFound("account with this address not found!").to_string()
39356+ );
39357+ assert_eq!(
39358+ db.update_account(AccountChangeset {
39359+ address: acc.address.clone(),
39360+ ..AccountChangeset::default()
39361+ })
39362+ .map_err(|err| err.to_string()),
39363+ Ok(())
39364+ );
39365+ assert_eq!(
39366+ db.update_account(AccountChangeset {
39367+ address: acc.address.clone(),
39368+ enabled: Some(Some(false)),
39369+ ..AccountChangeset::default()
39370+ })
39371+ .map_err(|err| err.to_string()),
39372+ Ok(())
39373+ );
39374+ assert!(!db.account(acc.pk()).unwrap().unwrap().enabled);
39375+ assert_eq!(
39376+ db.remove_account("4@example.com")
39377+ .map_err(|err| err.to_string()),
39378+ Ok(())
39379+ );
39380+ assert_eq!(db.accounts().unwrap(), vec![]);
39381+ }
39382+ }
39383 diff --git a/mailpot/src/templates.rs b/mailpot/src/templates.rs
39384new file mode 100644
39385index 0000000..3f1fb66
39386--- /dev/null
39387+++ b/mailpot/src/templates.rs
39388 @@ -0,0 +1,370 @@
39389+ /*
39390+ * This file is part of mailpot
39391+ *
39392+ * Copyright 2020 - Manos Pitsidianakis
39393+ *
39394+ * This program is free software: you can redistribute it and/or modify
39395+ * it under the terms of the GNU Affero General Public License as
39396+ * published by the Free Software Foundation, either version 3 of the
39397+ * License, or (at your option) any later version.
39398+ *
39399+ * This program is distributed in the hope that it will be useful,
39400+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
39401+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
39402+ * GNU Affero General Public License for more details.
39403+ *
39404+ * You should have received a copy of the GNU Affero General Public License
39405+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
39406+ */
39407+
39408+ //! Named templates, for generated e-mail like confirmations, alerts etc.
39409+ //!
39410+ //! Template database model: [`Template`].
39411+
39412+ use log::trace;
39413+ use rusqlite::OptionalExtension;
39414+
39415+ use crate::{
39416+ errors::{ErrorKind::*, *},
39417+ Connection, DbVal,
39418+ };
39419+
39420+ /// A named template.
39421+ #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
39422+ pub struct Template {
39423+ /// Database primary key.
39424+ pub pk: i64,
39425+ /// Name.
39426+ pub name: String,
39427+ /// Associated list foreign key, optional.
39428+ pub list: Option<i64>,
39429+ /// Subject template.
39430+ pub subject: Option<String>,
39431+ /// Extra headers template.
39432+ pub headers_json: Option<serde_json::Value>,
39433+ /// Body template.
39434+ pub body: String,
39435+ }
39436+
39437+ impl std::fmt::Display for Template {
39438+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
39439+ write!(fmt, "{:?}", self)
39440+ }
39441+ }
39442+
39443+ impl Template {
39444+ /// Template name for generic list help e-mail.
39445+ pub const GENERIC_HELP: &'static str = "generic-help";
39446+ /// Template name for generic failure e-mail.
39447+ pub const GENERIC_FAILURE: &'static str = "generic-failure";
39448+ /// Template name for generic success e-mail.
39449+ pub const GENERIC_SUCCESS: &'static str = "generic-success";
39450+ /// Template name for subscription confirmation e-mail.
39451+ pub const SUBSCRIPTION_CONFIRMATION: &'static str = "subscription-confirmation";
39452+ /// Template name for unsubscription confirmation e-mail.
39453+ pub const UNSUBSCRIPTION_CONFIRMATION: &'static str = "unsubscription-confirmation";
39454+ /// Template name for subscription request notice e-mail (for list owners).
39455+ pub const SUBSCRIPTION_REQUEST_NOTICE_OWNER: &'static str = "subscription-notice-owner";
39456+ /// Template name for subscription request acceptance e-mail (for the
39457+ /// candidates).
39458+ pub const SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT: &'static str =
39459+ "subscription-notice-candidate-accept";
39460+ /// Template name for admin notices.
39461+ pub const ADMIN_NOTICE: &'static str = "admin-notice";
39462+
39463+ /// Render a message body from a saved named template.
39464+ pub fn render(&self, context: minijinja::value::Value) -> Result<melib::Draft> {
39465+ use melib::{Draft, HeaderName};
39466+
39467+ let env = minijinja::Environment::new();
39468+ let mut draft: Draft = Draft {
39469+ body: env.render_named_str("body", &self.body, &context)?,
39470+ ..Draft::default()
39471+ };
39472+ if let Some(ref subject) = self.subject {
39473+ draft.headers.insert(
39474+ HeaderName::SUBJECT,
39475+ env.render_named_str("subject", subject, &context)?,
39476+ );
39477+ }
39478+
39479+ Ok(draft)
39480+ }
39481+
39482+ /// Template name for generic failure e-mail.
39483+ pub fn default_generic_failure() -> Self {
39484+ Self {
39485+ pk: -1,
39486+ name: Self::GENERIC_FAILURE.to_string(),
39487+ list: None,
39488+ subject: Some(
39489+ "{{ subject if subject else \"Your e-mail was not processed successfully.\" }}"
39490+ .to_string(),
39491+ ),
39492+ headers_json: None,
39493+ body: "{{ details|safe if details else \"The list owners and administrators have been \
39494+ notified.\" }}"
39495+ .to_string(),
39496+ }
39497+ }
39498+
39499+ /// Create a plain template for generic success e-mails.
39500+ pub fn default_generic_success() -> Self {
39501+ Self {
39502+ pk: -1,
39503+ name: Self::GENERIC_SUCCESS.to_string(),
39504+ list: None,
39505+ subject: Some(
39506+ "{{ subject if subject else \"Your e-mail was processed successfully.\" }}"
39507+ .to_string(),
39508+ ),
39509+ headers_json: None,
39510+ body: "{{ details|safe if details else \"\" }}".to_string(),
39511+ }
39512+ }
39513+
39514+ /// Create a plain template for subscription confirmation.
39515+ pub fn default_subscription_confirmation() -> Self {
39516+ Self {
39517+ pk: -1,
39518+ name: Self::SUBSCRIPTION_CONFIRMATION.to_string(),
39519+ list: None,
39520+ subject: Some(
39521+ "{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
39522+ %}You have successfully subscribed to {{ list.name if list.name else list.id \
39523+ }}{% else %}You have successfully subscribed to this list{% endif %}."
39524+ .to_string(),
39525+ ),
39526+ headers_json: None,
39527+ body: "{{ details|safe if details else \"\" }}".to_string(),
39528+ }
39529+ }
39530+
39531+ /// Create a plain template for unsubscription confirmations.
39532+ pub fn default_unsubscription_confirmation() -> Self {
39533+ Self {
39534+ pk: -1,
39535+ name: Self::UNSUBSCRIPTION_CONFIRMATION.to_string(),
39536+ list: None,
39537+ subject: Some(
39538+ "{% if list and (list.id or list.name) %}{% if list.id %}[{{ list.id }}] {% endif \
39539+ %}You have successfully unsubscribed from {{ list.name if list.name else list.id \
39540+ }}{% else %}You have successfully unsubscribed from this list{% endif %}."
39541+ .to_string(),
39542+ ),
39543+ headers_json: None,
39544+ body: "{{ details|safe if details else \"\" }}".to_string(),
39545+ }
39546+ }
39547+
39548+ /// Create a plain template for admin notices.
39549+ pub fn default_admin_notice() -> Self {
39550+ Self {
39551+ pk: -1,
39552+ name: Self::ADMIN_NOTICE.to_string(),
39553+ list: None,
39554+ subject: Some(
39555+ "{% if list %}An error occured with list {{ list.id }}{% else %}An error \
39556+ occured{% endif %}"
39557+ .to_string(),
39558+ ),
39559+ headers_json: None,
39560+ body: "{{ details|safe if details else \"\" }}".to_string(),
39561+ }
39562+ }
39563+
39564+ /// Create a plain template for subscription requests for list owners.
39565+ pub fn default_subscription_request_owner() -> Self {
39566+ Self {
39567+ pk: -1,
39568+ name: Self::SUBSCRIPTION_REQUEST_NOTICE_OWNER.to_string(),
39569+ list: None,
39570+ subject: Some("Subscription request for {{ list.id }}".to_string()),
39571+ headers_json: None,
39572+ body: "Candidate {{ candidate.name if candidate.name else \"\" }} <{{ \
39573+ candidate.address }}> Primary key: {{ candidate.pk }}\n\n{{ details|safe if \
39574+ details else \"\" }}"
39575+ .to_string(),
39576+ }
39577+ }
39578+
39579+ /// Create a plain template for subscription requests for candidates.
39580+ pub fn default_subscription_request_candidate_accept() -> Self {
39581+ Self {
39582+ pk: -1,
39583+ name: Self::SUBSCRIPTION_REQUEST_CANDIDATE_ACCEPT.to_string(),
39584+ list: None,
39585+ subject: Some("Your subscription to {{ list.id }} is now active.".to_string()),
39586+ headers_json: None,
39587+ body: "{{ details|safe if details else \"\" }}".to_string(),
39588+ }
39589+ }
39590+
39591+ /// Create a plain template for generic list help replies.
39592+ pub fn default_generic_help() -> Self {
39593+ Self {
39594+ pk: -1,
39595+ name: Self::GENERIC_HELP.to_string(),
39596+ list: None,
39597+ subject: Some("{{ subject if subject else \"Help for mailing list\" }}".to_string()),
39598+ headers_json: None,
39599+ body: "{{ details }}".to_string(),
39600+ }
39601+ }
39602+ }
39603+
39604+ impl Connection {
39605+ /// Fetch all.
39606+ pub fn fetch_templates(&self) -> Result<Vec<DbVal<Template>>> {
39607+ let mut stmt = self
39608+ .connection
39609+ .prepare("SELECT * FROM template ORDER BY pk;")?;
39610+ let iter = stmt.query_map(rusqlite::params![], |row| {
39611+ let pk = row.get("pk")?;
39612+ Ok(DbVal(
39613+ Template {
39614+ pk,
39615+ name: row.get("name")?,
39616+ list: row.get("list")?,
39617+ subject: row.get("subject")?,
39618+ headers_json: row.get("headers_json")?,
39619+ body: row.get("body")?,
39620+ },
39621+ pk,
39622+ ))
39623+ })?;
39624+
39625+ let mut ret = vec![];
39626+ for templ in iter {
39627+ let templ = templ?;
39628+ ret.push(templ);
39629+ }
39630+ Ok(ret)
39631+ }
39632+
39633+ /// Fetch a named template.
39634+ pub fn fetch_template(
39635+ &self,
39636+ template: &str,
39637+ list_pk: Option<i64>,
39638+ ) -> Result<Option<DbVal<Template>>> {
39639+ let mut stmt = self
39640+ .connection
39641+ .prepare("SELECT * FROM template WHERE name = ? AND list IS ?;")?;
39642+ let ret = stmt
39643+ .query_row(rusqlite::params![&template, &list_pk], |row| {
39644+ let pk = row.get("pk")?;
39645+ Ok(DbVal(
39646+ Template {
39647+ pk,
39648+ name: row.get("name")?,
39649+ list: row.get("list")?,
39650+ subject: row.get("subject")?,
39651+ headers_json: row.get("headers_json")?,
39652+ body: row.get("body")?,
39653+ },
39654+ pk,
39655+ ))
39656+ })
39657+ .optional()?;
39658+ if ret.is_none() && list_pk.is_some() {
39659+ let mut stmt = self
39660+ .connection
39661+ .prepare("SELECT * FROM template WHERE name = ? AND list IS NULL;")?;
39662+ Ok(stmt
39663+ .query_row(rusqlite::params![&template], |row| {
39664+ let pk = row.get("pk")?;
39665+ Ok(DbVal(
39666+ Template {
39667+ pk,
39668+ name: row.get("name")?,
39669+ list: row.get("list")?,
39670+ subject: row.get("subject")?,
39671+ headers_json: row.get("headers_json")?,
39672+ body: row.get("body")?,
39673+ },
39674+ pk,
39675+ ))
39676+ })
39677+ .optional()?)
39678+ } else {
39679+ Ok(ret)
39680+ }
39681+ }
39682+
39683+ /// Insert a named template.
39684+ pub fn add_template(&self, template: Template) -> Result<DbVal<Template>> {
39685+ let mut stmt = self.connection.prepare(
39686+ "INSERT INTO template(name, list, subject, headers_json, body) VALUES(?, ?, ?, ?, ?) \
39687+ RETURNING *;",
39688+ )?;
39689+ let ret = stmt
39690+ .query_row(
39691+ rusqlite::params![
39692+ &template.name,
39693+ &template.list,
39694+ &template.subject,
39695+ &template.headers_json,
39696+ &template.body
39697+ ],
39698+ |row| {
39699+ let pk = row.get("pk")?;
39700+ Ok(DbVal(
39701+ Template {
39702+ pk,
39703+ name: row.get("name")?,
39704+ list: row.get("list")?,
39705+ subject: row.get("subject")?,
39706+ headers_json: row.get("headers_json")?,
39707+ body: row.get("body")?,
39708+ },
39709+ pk,
39710+ ))
39711+ },
39712+ )
39713+ .map_err(|err| {
39714+ if matches!(
39715+ err,
39716+ rusqlite::Error::SqliteFailure(
39717+ rusqlite::ffi::Error {
39718+ code: rusqlite::ffi::ErrorCode::ConstraintViolation,
39719+ extended_code: 787
39720+ },
39721+ _
39722+ )
39723+ ) {
39724+ Error::from(err).chain_err(|| NotFound("Could not find a list with this pk."))
39725+ } else {
39726+ err.into()
39727+ }
39728+ })?;
39729+
39730+ trace!("add_template {:?}.", &ret);
39731+ Ok(ret)
39732+ }
39733+
39734+ /// Remove a named template.
39735+ pub fn remove_template(&self, template: &str, list_pk: Option<i64>) -> Result<Template> {
39736+ let mut stmt = self
39737+ .connection
39738+ .prepare("DELETE FROM template WHERE name = ? AND list IS ? RETURNING *;")?;
39739+ let ret = stmt.query_row(rusqlite::params![&template, &list_pk], |row| {
39740+ Ok(Template {
39741+ pk: -1,
39742+ name: row.get("name")?,
39743+ list: row.get("list")?,
39744+ subject: row.get("subject")?,
39745+ headers_json: row.get("headers_json")?,
39746+ body: row.get("body")?,
39747+ })
39748+ })?;
39749+
39750+ trace!(
39751+ "remove_template {} list_pk {:?} {:?}.",
39752+ template,
39753+ &list_pk,
39754+ &ret
39755+ );
39756+ Ok(ret)
39757+ }
39758+ }
39759 diff --git a/mailpot/tests/account.rs b/mailpot/tests/account.rs
39760new file mode 100644
39761index 0000000..f02a05f
39762--- /dev/null
39763+++ b/mailpot/tests/account.rs
39764 @@ -0,0 +1,145 @@
39765+ /*
39766+ * This file is part of mailpot
39767+ *
39768+ * Copyright 2020 - Manos Pitsidianakis
39769+ *
39770+ * This program is free software: you can redistribute it and/or modify
39771+ * it under the terms of the GNU Affero General Public License as
39772+ * published by the Free Software Foundation, either version 3 of the
39773+ * License, or (at your option) any later version.
39774+ *
39775+ * This program is distributed in the hope that it will be useful,
39776+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
39777+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
39778+ * GNU Affero General Public License for more details.
39779+ *
39780+ * You should have received a copy of the GNU Affero General Public License
39781+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
39782+ */
39783+
39784+ use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
39785+ use mailpot_tests::init_stderr_logging;
39786+ use tempfile::TempDir;
39787+
39788+ #[test]
39789+ fn test_accounts() {
39790+ init_stderr_logging();
39791+
39792+ const SSH_KEY: &[u8] = include_bytes!("./ssh_key.pub");
39793+
39794+ let tmp_dir = TempDir::new().unwrap();
39795+
39796+ let db_path = tmp_dir.path().join("mpot.db");
39797+ let config = Configuration {
39798+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
39799+ db_path,
39800+ data_path: tmp_dir.path().to_path_buf(),
39801+ administrators: vec![],
39802+ };
39803+
39804+ let db = Connection::open_or_create_db(config).unwrap().trusted();
39805+ assert!(db.lists().unwrap().is_empty());
39806+ let foo_chat = db
39807+ .create_list(MailingList {
39808+ pk: 0,
39809+ name: "foobar chat".into(),
39810+ id: "foo-chat".into(),
39811+ address: "foo-chat@example.com".into(),
39812+ description: None,
39813+ topics: vec![],
39814+ archive_url: None,
39815+ })
39816+ .unwrap();
39817+
39818+ assert_eq!(foo_chat.pk(), 1);
39819+ let lists = db.lists().unwrap();
39820+ assert_eq!(lists.len(), 1);
39821+ assert_eq!(lists[0], foo_chat);
39822+ let post_policy = db
39823+ .set_list_post_policy(PostPolicy {
39824+ pk: 0,
39825+ list: foo_chat.pk(),
39826+ announce_only: false,
39827+ subscription_only: true,
39828+ approval_needed: false,
39829+ open: false,
39830+ custom: false,
39831+ })
39832+ .unwrap();
39833+
39834+ assert_eq!(post_policy.pk(), 1);
39835+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
39836+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
39837+
39838+ let db = db.untrusted();
39839+
39840+ let subscribe_bytes = b"From: Name <user@example.com>
39841+ To: <foo-chat+subscribe@example.com>
39842+ Subject: subscribe
39843+ Date: Thu, 29 Oct 2020 13:58:16 +0000
39844+ Message-ID: <abcdefgh@sator.example.com>
39845+ Content-Language: en-US
39846+ Content-Type: text/html
39847+ Content-Transfer-Encoding: base64
39848+ MIME-Version: 1.0
39849+
39850+ ";
39851+ let envelope =
39852+ melib::Envelope::from_bytes(subscribe_bytes, None).expect("Could not parse message");
39853+ db.post(&envelope, subscribe_bytes, /* dry_run */ false)
39854+ .unwrap();
39855+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
39856+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
39857+
39858+ assert_eq!(db.account_by_address("user@example.com").unwrap(), None);
39859+
39860+ println!(
39861+ "Check that sending a password request without having an account creates the account."
39862+ );
39863+ const PASSWORD_REQ: &[u8] = b"From: Name <user@example.com>
39864+ To: <foo-chat+request@example.com>
39865+ Subject: password
39866+ Date: Thu, 29 Oct 2020 13:58:16 +0000
39867+ Message-ID: <abcdefgh@sator.example.com>
39868+ Content-Language: en-US
39869+ Content-Type: text/plain; charset=ascii
39870+ Content-Transfer-Encoding: 8bit
39871+ MIME-Version: 1.0
39872+
39873+ ";
39874+ let mut set_password_bytes = PASSWORD_REQ.to_vec();
39875+ set_password_bytes.extend(SSH_KEY.iter().cloned());
39876+
39877+ let envelope =
39878+ melib::Envelope::from_bytes(&set_password_bytes, None).expect("Could not parse message");
39879+ db.post(&envelope, &set_password_bytes, /* dry_run */ false)
39880+ .unwrap();
39881+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
39882+ let acc = db.account_by_address("user@example.com").unwrap().unwrap();
39883+
39884+ assert_eq!(
39885+ acc.password.as_bytes(),
39886+ SSH_KEY,
39887+ "SSH public key / passwords didn't match. Account has {:?} but expected {:?}",
39888+ String::from_utf8_lossy(acc.password.as_bytes()),
39889+ String::from_utf8_lossy(SSH_KEY)
39890+ );
39891+
39892+ println!("Check that sending a password request with an account updates the password field.");
39893+
39894+ let mut set_password_bytes = PASSWORD_REQ.to_vec();
39895+ set_password_bytes.push(b'a');
39896+ set_password_bytes.extend(SSH_KEY.iter().cloned());
39897+
39898+ let envelope =
39899+ melib::Envelope::from_bytes(&set_password_bytes, None).expect("Could not parse message");
39900+ db.post(&envelope, &set_password_bytes, /* dry_run */ false)
39901+ .unwrap();
39902+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
39903+ let acc = db.account_by_address("user@example.com").unwrap().unwrap();
39904+
39905+ assert!(
39906+ acc.password.as_bytes() != SSH_KEY,
39907+ "SSH public key / password should have changed.",
39908+ );
39909+ }
39910 diff --git a/mailpot/tests/authorizer.rs b/mailpot/tests/authorizer.rs
39911new file mode 100644
39912index 0000000..f4e124a
39913--- /dev/null
39914+++ b/mailpot/tests/authorizer.rs
39915 @@ -0,0 +1,113 @@
39916+ /*
39917+ * This file is part of mailpot
39918+ *
39919+ * Copyright 2020 - Manos Pitsidianakis
39920+ *
39921+ * This program is free software: you can redistribute it and/or modify
39922+ * it under the terms of the GNU Affero General Public License as
39923+ * published by the Free Software Foundation, either version 3 of the
39924+ * License, or (at your option) any later version.
39925+ *
39926+ * This program is distributed in the hope that it will be useful,
39927+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
39928+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
39929+ * GNU Affero General Public License for more details.
39930+ *
39931+ * You should have received a copy of the GNU Affero General Public License
39932+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
39933+ */
39934+
39935+ use mailpot::{models::*, Configuration, Connection, ErrorKind, SendMail};
39936+ use mailpot_tests::init_stderr_logging;
39937+ use tempfile::TempDir;
39938+
39939+ #[test]
39940+ fn test_authorizer() {
39941+ init_stderr_logging();
39942+ let tmp_dir = TempDir::new().unwrap();
39943+
39944+ let db_path = tmp_dir.path().join("mpot.db");
39945+ let config = Configuration {
39946+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
39947+ db_path,
39948+ data_path: tmp_dir.path().to_path_buf(),
39949+ administrators: vec![],
39950+ };
39951+
39952+ let db = Connection::open_or_create_db(config).unwrap();
39953+ assert!(db.lists().unwrap().is_empty());
39954+
39955+ for err in [
39956+ db.create_list(MailingList {
39957+ pk: 0,
39958+ name: "foobar chat".into(),
39959+ id: "foo-chat".into(),
39960+ address: "foo-chat@example.com".into(),
39961+ description: None,
39962+ topics: vec![],
39963+ archive_url: None,
39964+ })
39965+ .unwrap_err(),
39966+ db.remove_list_owner(1, 1).unwrap_err(),
39967+ db.remove_list_post_policy(1, 1).unwrap_err(),
39968+ db.set_list_post_policy(PostPolicy {
39969+ pk: 0,
39970+ list: 1,
39971+ announce_only: false,
39972+ subscription_only: true,
39973+ approval_needed: false,
39974+ open: false,
39975+ custom: false,
39976+ })
39977+ .unwrap_err(),
39978+ ] {
39979+ assert_eq!(
39980+ err.kind().to_string(),
39981+ ErrorKind::Sql(rusqlite::Error::SqliteFailure(
39982+ rusqlite::ffi::Error {
39983+ code: rusqlite::ErrorCode::AuthorizationForStatementDenied,
39984+ extended_code: 23,
39985+ },
39986+ Some("not authorized".into()),
39987+ ))
39988+ .to_string()
39989+ );
39990+ }
39991+ assert!(db.lists().unwrap().is_empty());
39992+
39993+ let db = db.trusted();
39994+
39995+ for ok in [
39996+ db.create_list(MailingList {
39997+ pk: 0,
39998+ name: "foobar chat".into(),
39999+ id: "foo-chat".into(),
40000+ address: "foo-chat@example.com".into(),
40001+ description: None,
40002+ topics: vec![],
40003+ archive_url: None,
40004+ })
40005+ .map(|_| ()),
40006+ db.add_list_owner(ListOwner {
40007+ pk: 0,
40008+ list: 1,
40009+ address: String::new(),
40010+ name: None,
40011+ })
40012+ .map(|_| ()),
40013+ db.set_list_post_policy(PostPolicy {
40014+ pk: 0,
40015+ list: 1,
40016+ announce_only: false,
40017+ subscription_only: true,
40018+ approval_needed: false,
40019+ open: false,
40020+ custom: false,
40021+ })
40022+ .map(|_| ()),
40023+ db.remove_list_post_policy(1, 1).map(|_| ()),
40024+ db.remove_list_owner(1, 1).map(|_| ()),
40025+ ] {
40026+ ok.unwrap();
40027+ }
40028+ }
40029 diff --git a/mailpot/tests/creation.rs b/mailpot/tests/creation.rs
40030new file mode 100644
40031index 0000000..31aa0cc
40032--- /dev/null
40033+++ b/mailpot/tests/creation.rs
40034 @@ -0,0 +1,73 @@
40035+ /*
40036+ * This file is part of mailpot
40037+ *
40038+ * Copyright 2020 - Manos Pitsidianakis
40039+ *
40040+ * This program is free software: you can redistribute it and/or modify
40041+ * it under the terms of the GNU Affero General Public License as
40042+ * published by the Free Software Foundation, either version 3 of the
40043+ * License, or (at your option) any later version.
40044+ *
40045+ * This program is distributed in the hope that it will be useful,
40046+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
40047+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
40048+ * GNU Affero General Public License for more details.
40049+ *
40050+ * You should have received a copy of the GNU Affero General Public License
40051+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
40052+ */
40053+
40054+ use mailpot::{models::*, Configuration, Connection, SendMail};
40055+ use mailpot_tests::init_stderr_logging;
40056+ use tempfile::TempDir;
40057+
40058+ #[test]
40059+ fn test_init_empty() {
40060+ init_stderr_logging();
40061+ let tmp_dir = TempDir::new().unwrap();
40062+
40063+ let db_path = tmp_dir.path().join("mpot.db");
40064+ let config = Configuration {
40065+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
40066+ db_path,
40067+ data_path: tmp_dir.path().to_path_buf(),
40068+ administrators: vec![],
40069+ };
40070+
40071+ let db = Connection::open_or_create_db(config).unwrap();
40072+
40073+ assert!(db.lists().unwrap().is_empty());
40074+ }
40075+
40076+ #[test]
40077+ fn test_list_creation() {
40078+ init_stderr_logging();
40079+ let tmp_dir = TempDir::new().unwrap();
40080+
40081+ let db_path = tmp_dir.path().join("mpot.db");
40082+ let config = Configuration {
40083+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
40084+ db_path,
40085+ data_path: tmp_dir.path().to_path_buf(),
40086+ administrators: vec![],
40087+ };
40088+
40089+ let db = Connection::open_or_create_db(config).unwrap().trusted();
40090+ assert!(db.lists().unwrap().is_empty());
40091+ let foo_chat = db
40092+ .create_list(MailingList {
40093+ pk: 0,
40094+ name: "foobar chat".into(),
40095+ id: "foo-chat".into(),
40096+ address: "foo-chat@example.com".into(),
40097+ description: None,
40098+ topics: vec![],
40099+ archive_url: None,
40100+ })
40101+ .unwrap();
40102+
40103+ assert_eq!(foo_chat.pk(), 1);
40104+ let lists = db.lists().unwrap();
40105+ assert_eq!(lists.len(), 1);
40106+ assert_eq!(lists[0], foo_chat);
40107+ }
40108 diff --git a/mailpot/tests/error_queue.rs b/mailpot/tests/error_queue.rs
40109new file mode 100644
40110index 0000000..ed8a117
40111--- /dev/null
40112+++ b/mailpot/tests/error_queue.rs
40113 @@ -0,0 +1,96 @@
40114+ /*
40115+ * This file is part of mailpot
40116+ *
40117+ * Copyright 2020 - Manos Pitsidianakis
40118+ *
40119+ * This program is free software: you can redistribute it and/or modify
40120+ * it under the terms of the GNU Affero General Public License as
40121+ * published by the Free Software Foundation, either version 3 of the
40122+ * License, or (at your option) any later version.
40123+ *
40124+ * This program is distributed in the hope that it will be useful,
40125+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
40126+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
40127+ * GNU Affero General Public License for more details.
40128+ *
40129+ * You should have received a copy of the GNU Affero General Public License
40130+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
40131+ */
40132+
40133+ use mailpot::{melib, models::*, queue::Queue, Configuration, Connection, SendMail};
40134+ use mailpot_tests::init_stderr_logging;
40135+ use tempfile::TempDir;
40136+
40137+ fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
40138+ use melib::smtp::*;
40139+ SmtpServerConf {
40140+ hostname: "127.0.0.1".into(),
40141+ port: 8825,
40142+ envelope_from: "foo-chat@example.com".into(),
40143+ auth: SmtpAuth::None,
40144+ security: SmtpSecurity::None,
40145+ extensions: Default::default(),
40146+ }
40147+ }
40148+
40149+ #[test]
40150+ fn test_error_queue() {
40151+ init_stderr_logging();
40152+ let tmp_dir = TempDir::new().unwrap();
40153+
40154+ let db_path = tmp_dir.path().join("mpot.db");
40155+ let config = Configuration {
40156+ send_mail: SendMail::Smtp(get_smtp_conf()),
40157+ db_path,
40158+ data_path: tmp_dir.path().to_path_buf(),
40159+ administrators: vec![],
40160+ };
40161+
40162+ let db = Connection::open_or_create_db(config).unwrap().trusted();
40163+ assert!(db.lists().unwrap().is_empty());
40164+ let foo_chat = db
40165+ .create_list(MailingList {
40166+ pk: 0,
40167+ name: "foobar chat".into(),
40168+ id: "foo-chat".into(),
40169+ address: "foo-chat@example.com".into(),
40170+ description: None,
40171+ topics: vec![],
40172+ archive_url: None,
40173+ })
40174+ .unwrap();
40175+
40176+ assert_eq!(foo_chat.pk(), 1);
40177+ let post_policy = db
40178+ .set_list_post_policy(PostPolicy {
40179+ pk: 0,
40180+ list: foo_chat.pk(),
40181+ announce_only: false,
40182+ subscription_only: true,
40183+ approval_needed: false,
40184+ open: false,
40185+ custom: false,
40186+ })
40187+ .unwrap();
40188+
40189+ assert_eq!(post_policy.pk(), 1);
40190+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
40191+
40192+ // drop privileges
40193+ let db = db.untrusted();
40194+
40195+ let input_bytes = include_bytes!("./test_sample_longmessage.eml");
40196+ let envelope = melib::Envelope::from_bytes(input_bytes, None).expect("Could not parse message");
40197+ db.post(&envelope, input_bytes, /* dry_run */ false)
40198+ .expect("Got unexpected error");
40199+ let out = db.queue(Queue::Out).unwrap();
40200+ assert_eq!(out.len(), 1);
40201+ const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
40202+ assert_eq!(
40203+ out[0]
40204+ .comment
40205+ .as_ref()
40206+ .and_then(|c| c.get(..COMMENT_PREFIX.len())),
40207+ Some(COMMENT_PREFIX)
40208+ );
40209+ }
40210 diff --git a/mailpot/tests/migrations.rs b/mailpot/tests/migrations.rs
40211new file mode 100644
40212index 0000000..69d8da6
40213--- /dev/null
40214+++ b/mailpot/tests/migrations.rs
40215 @@ -0,0 +1,343 @@
40216+ /*
40217+ * This file is part of mailpot
40218+ *
40219+ * Copyright 2020 - Manos Pitsidianakis
40220+ *
40221+ * This program is free software: you can redistribute it and/or modify
40222+ * it under the terms of the GNU Affero General Public License as
40223+ * published by the Free Software Foundation, either version 3 of the
40224+ * License, or (at your option) any later version.
40225+ *
40226+ * This program is distributed in the hope that it will be useful,
40227+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
40228+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
40229+ * GNU Affero General Public License for more details.
40230+ *
40231+ * You should have received a copy of the GNU Affero General Public License
40232+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
40233+ */
40234+
40235+ use std::fs::{File, OpenOptions};
40236+
40237+ use mailpot::{Configuration, Connection, SendMail};
40238+ use mailpot_tests::init_stderr_logging;
40239+ use tempfile::TempDir;
40240+
40241+ include!("../build/make_migrations.rs");
40242+
40243+ #[test]
40244+ fn test_init_empty() {
40245+ init_stderr_logging();
40246+ let tmp_dir = TempDir::new().unwrap();
40247+
40248+ let db_path = tmp_dir.path().join("mpot.db");
40249+ let config = Configuration {
40250+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
40251+ db_path,
40252+ data_path: tmp_dir.path().to_path_buf(),
40253+ administrators: vec![],
40254+ };
40255+
40256+ let db = Connection::open_or_create_db(config).unwrap().trusted();
40257+
40258+ let migrations = Connection::MIGRATIONS;
40259+ if migrations.is_empty() {
40260+ return;
40261+ }
40262+
40263+ let version = db.schema_version().unwrap();
40264+
40265+ assert_eq!(version, migrations[migrations.len() - 1].0);
40266+
40267+ db.migrate(version, migrations[0].0).unwrap();
40268+
40269+ db.migrate(migrations[0].0, version).unwrap();
40270+ }
40271+
40272+ trait ConnectionExt {
40273+ fn schema_version(&self) -> Result<u32, rusqlite::Error>;
40274+ fn migrate(
40275+ &mut self,
40276+ from: u32,
40277+ to: u32,
40278+ migrations: &[(u32, &str, &str)],
40279+ ) -> Result<(), rusqlite::Error>;
40280+ }
40281+
40282+ impl ConnectionExt for rusqlite::Connection {
40283+ fn schema_version(&self) -> Result<u32, rusqlite::Error> {
40284+ self.prepare("SELECT user_version FROM pragma_user_version;")?
40285+ .query_row([], |row| {
40286+ let v: u32 = row.get(0)?;
40287+ Ok(v)
40288+ })
40289+ }
40290+
40291+ fn migrate(
40292+ &mut self,
40293+ mut from: u32,
40294+ to: u32,
40295+ migrations: &[(u32, &str, &str)],
40296+ ) -> Result<(), rusqlite::Error> {
40297+ if from == to {
40298+ return Ok(());
40299+ }
40300+
40301+ let undo = from > to;
40302+ let tx = self.transaction()?;
40303+
40304+ loop {
40305+ log::trace!(
40306+ "exec migration from {from} to {to}, type: {}do",
40307+ if undo { "un" } else { "re" }
40308+ );
40309+ if undo {
40310+ log::trace!("{}", migrations[from as usize - 1].2);
40311+ tx.execute_batch(migrations[from as usize - 1].2)?;
40312+ from -= 1;
40313+ if from == to {
40314+ break;
40315+ }
40316+ } else {
40317+ if from != 0 {
40318+ log::trace!("{}", migrations[from as usize - 1].1);
40319+ tx.execute_batch(migrations[from as usize - 1].1)?;
40320+ }
40321+ from += 1;
40322+ if from == to + 1 {
40323+ break;
40324+ }
40325+ }
40326+ }
40327+ tx.pragma_update(
40328+ None,
40329+ "user_version",
40330+ if to == 0 {
40331+ 0
40332+ } else {
40333+ migrations[to as usize - 1].0
40334+ },
40335+ )?;
40336+
40337+ tx.commit()?;
40338+ Ok(())
40339+ }
40340+ }
40341+
40342+ const FIRST_SCHEMA: &str = r#"
40343+ PRAGMA foreign_keys = true;
40344+ PRAGMA encoding = 'UTF-8';
40345+ PRAGMA schema_version = 0;
40346+
40347+ CREATE TABLE IF NOT EXISTS person (
40348+ pk INTEGER PRIMARY KEY NOT NULL,
40349+ name TEXT,
40350+ address TEXT NOT NULL,
40351+ created INTEGER NOT NULL DEFAULT (unixepoch()),
40352+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
40353+ );
40354+ "#;
40355+
40356+ const MIGRATIONS: &[(u32, &str, &str)] = &[
40357+ (
40358+ 1,
40359+ "ALTER TABLE PERSON ADD COLUMN interests TEXT;",
40360+ "ALTER TABLE PERSON DROP COLUMN interests;",
40361+ ),
40362+ (
40363+ 2,
40364+ "CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);",
40365+ "DROP TABLE hobby;",
40366+ ),
40367+ (
40368+ 3,
40369+ "ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;",
40370+ "ALTER TABLE PERSON DROP COLUMN main_hobby;",
40371+ ),
40372+ ];
40373+
40374+ #[test]
40375+ fn test_migration_gen() {
40376+ init_stderr_logging();
40377+ let tmp_dir = TempDir::new().unwrap();
40378+ let in_path = tmp_dir.path().join("migrations");
40379+ std::fs::create_dir(&in_path).unwrap();
40380+ let out_path = tmp_dir.path().join("migrations.txt");
40381+ for (num, redo, undo) in MIGRATIONS.iter() {
40382+ let mut redo_file = File::options()
40383+ .write(true)
40384+ .create(true)
40385+ .truncate(true)
40386+ .open(&in_path.join(&format!("{num:03}.sql")))
40387+ .unwrap();
40388+ redo_file.write_all(redo.as_bytes()).unwrap();
40389+ redo_file.flush().unwrap();
40390+
40391+ let mut undo_file = File::options()
40392+ .write(true)
40393+ .create(true)
40394+ .truncate(true)
40395+ .open(&in_path.join(&format!("{num:03}.undo.sql")))
40396+ .unwrap();
40397+ undo_file.write_all(undo.as_bytes()).unwrap();
40398+ undo_file.flush().unwrap();
40399+ }
40400+
40401+ make_migrations(&in_path, &out_path, &mut vec![]);
40402+ let output = std::fs::read_to_string(&out_path).unwrap();
40403+ assert_eq!(&output.replace([' ', '\n'], ""), &r###"//(user_version, redo sql, undo sql
40404+ &[(1,r##"ALTER TABLE PERSON ADD COLUMN interests TEXT;"##,r##"ALTER TABLE PERSON DROP COLUMN interests;"##),(2,r##"CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);"##,r##"DROP TABLE hobby;"##),(3,r##"ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;"##,r##"ALTER TABLE PERSON DROP COLUMN main_hobby;"##),]"###.replace([' ', '\n'], ""));
40405+ }
40406+
40407+ #[test]
40408+ #[should_panic]
40409+ fn test_migration_gen_panic() {
40410+ init_stderr_logging();
40411+ let tmp_dir = TempDir::new().unwrap();
40412+ let in_path = tmp_dir.path().join("migrations");
40413+ std::fs::create_dir(&in_path).unwrap();
40414+ let out_path = tmp_dir.path().join("migrations.txt");
40415+ for (num, redo, undo) in MIGRATIONS.iter().skip(1) {
40416+ let mut redo_file = File::options()
40417+ .write(true)
40418+ .create(true)
40419+ .truncate(true)
40420+ .open(&in_path.join(&format!("{num:03}.sql")))
40421+ .unwrap();
40422+ redo_file.write_all(redo.as_bytes()).unwrap();
40423+ redo_file.flush().unwrap();
40424+
40425+ let mut undo_file = File::options()
40426+ .write(true)
40427+ .create(true)
40428+ .truncate(true)
40429+ .open(&in_path.join(&format!("{num:03}.undo.sql")))
40430+ .unwrap();
40431+ undo_file.write_all(undo.as_bytes()).unwrap();
40432+ undo_file.flush().unwrap();
40433+ }
40434+
40435+ make_migrations(&in_path, &out_path, &mut vec![]);
40436+ let output = std::fs::read_to_string(&out_path).unwrap();
40437+ assert_eq!(&output.replace([' ','\n'], ""), &r#"//(user_version, redo sql, undo sql
40438+ &[(1,"ALTER TABLE PERSON ADD COLUMN interests TEXT;","ALTER TABLE PERSON DROP COLUMN interests;"),(2,"CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);","DROP TABLE hobby;"),(3,"ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;","ALTER TABLE PERSON DROP COLUMN main_hobby;"),]"#.replace([' ', '\n'], ""));
40439+ }
40440+
40441+ #[test]
40442+ fn test_migration() {
40443+ init_stderr_logging();
40444+ let tmp_dir = TempDir::new().unwrap();
40445+ let db_path = tmp_dir.path().join("migr.db");
40446+
40447+ let mut conn = rusqlite::Connection::open(db_path.to_str().unwrap()).unwrap();
40448+ conn.execute_batch(FIRST_SCHEMA).unwrap();
40449+
40450+ conn.execute_batch(
40451+ "INSERT INTO person(name,address) VALUES('John Doe', 'johndoe@example.com');",
40452+ )
40453+ .unwrap();
40454+
40455+ let version = conn.schema_version().unwrap();
40456+ log::trace!("initial schema version is {}", version);
40457+
40458+ //assert_eq!(version, migrations[migrations.len() - 1].0);
40459+
40460+ conn.migrate(version, MIGRATIONS.last().unwrap().0, MIGRATIONS)
40461+ .unwrap();
40462+ /*
40463+ * CREATE TABLE sqlite_schema (
40464+ type text,
40465+ name text,
40466+ tbl_name text,
40467+ rootpage integer,
40468+ sql text
40469+ );
40470+ */
40471+ let get_sql = |table: &str, conn: &rusqlite::Connection| -> String {
40472+ conn.prepare("SELECT sql FROM sqlite_schema WHERE name = ?;")
40473+ .unwrap()
40474+ .query_row([table], |row| {
40475+ let sql: String = row.get(0)?;
40476+ Ok(sql)
40477+ })
40478+ .unwrap()
40479+ };
40480+
40481+ let strip_ws = |sql: &str| -> String { sql.replace([' ', '\n'], "") };
40482+
40483+ let person_sql: String = get_sql("person", &conn);
40484+ assert_eq!(
40485+ &strip_ws(&person_sql),
40486+ &strip_ws(
40487+ r#"
40488+ CREATE TABLE person (
40489+ pk INTEGER PRIMARY KEY NOT NULL,
40490+ name TEXT,
40491+ address TEXT NOT NULL,
40492+ created INTEGER NOT NULL DEFAULT (unixepoch()),
40493+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
40494+ interests TEXT,
40495+ main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL
40496+ )"#
40497+ )
40498+ );
40499+ let hobby_sql: String = get_sql("hobby", &conn);
40500+ assert_eq!(
40501+ &strip_ws(&hobby_sql),
40502+ &strip_ws(
40503+ r#"CREATE TABLE hobby (
40504+ pk INTEGER PRIMARY KEY NOT NULL,
40505+ title TEXT NOT NULL
40506+ )"#
40507+ )
40508+ );
40509+ conn.execute_batch(
40510+ r#"
40511+ INSERT INTO hobby(title) VALUES('fishing');
40512+ INSERT INTO hobby(title) VALUES('reading books');
40513+ INSERT INTO hobby(title) VALUES('running');
40514+ INSERT INTO hobby(title) VALUES('forest walks');
40515+ UPDATE person SET main_hobby = hpk FROM (SELECT pk AS hpk FROM hobby LIMIT 1) WHERE name = 'John Doe';
40516+ "#
40517+ )
40518+ .unwrap();
40519+ log::trace!(
40520+ "John Doe's main hobby is {:?}",
40521+ conn.prepare(
40522+ "SELECT pk, title FROM hobby WHERE EXISTS (SELECT 1 FROM person AS p WHERE \
40523+ p.main_hobby = pk);"
40524+ )
40525+ .unwrap()
40526+ .query_row([], |row| {
40527+ let pk: i64 = row.get(0)?;
40528+ let title: String = row.get(1)?;
40529+ Ok((pk, title))
40530+ })
40531+ .unwrap()
40532+ );
40533+
40534+ conn.migrate(MIGRATIONS.last().unwrap().0, 0, MIGRATIONS)
40535+ .unwrap();
40536+
40537+ assert_eq!(
40538+ conn.prepare("SELECT sql FROM sqlite_schema WHERE name = 'hobby';")
40539+ .unwrap()
40540+ .query_row([], |row| { row.get::<_, String>(0) })
40541+ .unwrap_err(),
40542+ rusqlite::Error::QueryReturnedNoRows
40543+ );
40544+ let person_sql: String = get_sql("person", &conn);
40545+ assert_eq!(
40546+ &strip_ws(&person_sql),
40547+ &strip_ws(
40548+ r#"
40549+ CREATE TABLE person (
40550+ pk INTEGER PRIMARY KEY NOT NULL,
40551+ name TEXT,
40552+ address TEXT NOT NULL,
40553+ created INTEGER NOT NULL DEFAULT (unixepoch()),
40554+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
40555+ )"#
40556+ )
40557+ );
40558+ }
40559 diff --git a/mailpot/tests/settings_json.rs b/mailpot/tests/settings_json.rs
40560new file mode 100644
40561index 0000000..82d459d
40562--- /dev/null
40563+++ b/mailpot/tests/settings_json.rs
40564 @@ -0,0 +1,223 @@
40565+ /*
40566+ * This file is part of mailpot
40567+ *
40568+ * Copyright 2023 - Manos Pitsidianakis
40569+ *
40570+ * This program is free software: you can redistribute it and/or modify
40571+ * it under the terms of the GNU Affero General Public License as
40572+ * published by the Free Software Foundation, either version 3 of the
40573+ * License, or (at your option) any later version.
40574+ *
40575+ * This program is distributed in the hope that it will be useful,
40576+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
40577+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
40578+ * GNU Affero General Public License for more details.
40579+ *
40580+ * You should have received a copy of the GNU Affero General Public License
40581+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
40582+ */
40583+
40584+ use jsonschema::JSONSchema;
40585+ use mailpot::{Configuration, Connection, SendMail};
40586+ use mailpot_tests::init_stderr_logging;
40587+ use serde_json::{json, Value};
40588+ use tempfile::TempDir;
40589+
40590+ #[test]
40591+ fn test_settings_json() {
40592+ init_stderr_logging();
40593+ let tmp_dir = TempDir::new().unwrap();
40594+
40595+ let db_path = tmp_dir.path().join("mpot.db");
40596+ std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
40597+ let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
40598+ #[allow(clippy::permissions_set_readonly_false)]
40599+ perms.set_readonly(false);
40600+ std::fs::set_permissions(&db_path, perms).unwrap();
40601+
40602+ let config = Configuration {
40603+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
40604+ db_path,
40605+ data_path: tmp_dir.path().to_path_buf(),
40606+ administrators: vec![],
40607+ };
40608+ let db = Connection::open_or_create_db(config).unwrap().trusted();
40609+ let list = db.lists().unwrap().remove(0);
40610+
40611+ let archived_at_link_settings_schema =
40612+ std::fs::read_to_string("./settings_json_schemas/archivedatlink.json").unwrap();
40613+
40614+ println!("Testing that inserting settings works…");
40615+ let (settings_pk, settings_val, last_modified): (i64, Value, i64) = {
40616+ let mut stmt = db
40617+ .connection
40618+ .prepare(
40619+ "INSERT INTO list_settings_json(name, list, value) \
40620+ VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value, last_modified;",
40621+ )
40622+ .unwrap();
40623+ stmt.query_row(
40624+ rusqlite::params![
40625+ &list.pk(),
40626+ &json!({
40627+ "template": "https://www.example.com/{{msg_id}}.html",
40628+ "preserve_carets": false
40629+ }),
40630+ ],
40631+ |row| {
40632+ let pk: i64 = row.get("pk")?;
40633+ let value: Value = row.get("value")?;
40634+ let last_modified: i64 = row.get("last_modified")?;
40635+ Ok((pk, value, last_modified))
40636+ },
40637+ )
40638+ .unwrap()
40639+ };
40640+ db.connection
40641+ .execute_batch("UPDATE list_settings_json SET is_valid = 1;")
40642+ .unwrap();
40643+
40644+ println!("Testing that schema is actually valid…");
40645+ let schema: Value = serde_json::from_str(&archived_at_link_settings_schema).unwrap();
40646+ let compiled = JSONSchema::compile(&schema).expect("A valid schema");
40647+ if let Err(errors) = compiled.validate(&settings_val) {
40648+ for err in errors {
40649+ eprintln!("Error: {err}");
40650+ }
40651+ panic!("Could not validate settings.");
40652+ };
40653+
40654+ println!("Testing that inserting invalid settings aborts…");
40655+ {
40656+ let mut stmt = db
40657+ .connection
40658+ .prepare(
40659+ "INSERT OR REPLACE INTO list_settings_json(name, list, value) \
40660+ VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value;",
40661+ )
40662+ .unwrap();
40663+ assert_eq!(
40664+ "new settings value is not valid according to the json schema. Rolling back \
40665+ transaction.",
40666+ &stmt
40667+ .query_row(
40668+ rusqlite::params![
40669+ &list.pk(),
40670+ &json!({
40671+ "template": "https://www.example.com/msg-id}.html" // should be msg_id
40672+ }),
40673+ ],
40674+ |row| {
40675+ let pk: i64 = row.get("pk")?;
40676+ let value: Value = row.get("value")?;
40677+ Ok((pk, value))
40678+ },
40679+ )
40680+ .unwrap_err()
40681+ .to_string()
40682+ );
40683+ };
40684+
40685+ println!("Testing that updating settings with invalid value aborts…");
40686+ {
40687+ let mut stmt = db
40688+ .connection
40689+ .prepare(
40690+ "UPDATE list_settings_json SET value = ? WHERE name = 'ArchivedAtLinkSettings' \
40691+ RETURNING pk, value;",
40692+ )
40693+ .unwrap();
40694+ assert_eq!(
40695+ "new settings value is not valid according to the json schema. Rolling back \
40696+ transaction.",
40697+ &stmt
40698+ .query_row(
40699+ rusqlite::params![&json!({
40700+ "template": "https://www.example.com/msg-id}.html" // should be msg_id
40701+ }),],
40702+ |row| {
40703+ let pk: i64 = row.get("pk")?;
40704+ let value: Value = row.get("value")?;
40705+ Ok((pk, value))
40706+ },
40707+ )
40708+ .unwrap_err()
40709+ .to_string()
40710+ );
40711+ };
40712+
40713+ std::thread::sleep(std::time::Duration::from_millis(1000));
40714+ println!("Finally, testing that updating schema reverifies settings…");
40715+ {
40716+ let mut stmt = db
40717+ .connection
40718+ .prepare(
40719+ "UPDATE settings_json_schema SET id = ? WHERE id = 'ArchivedAtLinkSettings' \
40720+ RETURNING pk;",
40721+ )
40722+ .unwrap();
40723+ stmt.query_row([&"ArchivedAtLinkSettingsv2"], |_| Ok(()))
40724+ .unwrap();
40725+ };
40726+ let (new_name, is_valid, new_last_modified): (String, bool, i64) = {
40727+ let mut stmt = db
40728+ .connection
40729+ .prepare("SELECT name, is_valid, last_modified from list_settings_json WHERE pk = ?;")
40730+ .unwrap();
40731+ stmt.query_row([&settings_pk], |row| {
40732+ Ok((
40733+ row.get("name")?,
40734+ row.get("is_valid")?,
40735+ row.get("last_modified")?,
40736+ ))
40737+ })
40738+ .unwrap()
40739+ };
40740+ assert_eq!(&new_name, "ArchivedAtLinkSettingsv2");
40741+ assert!(is_valid);
40742+ assert!(new_last_modified != last_modified);
40743+ }
40744+
40745+ #[test]
40746+ fn test_settings_json_schemas() {
40747+ init_stderr_logging();
40748+ let tmp_dir = TempDir::new().unwrap();
40749+
40750+ let db_path = tmp_dir.path().join("mpot.db");
40751+ std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
40752+ let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
40753+ #[allow(clippy::permissions_set_readonly_false)]
40754+ perms.set_readonly(false);
40755+ std::fs::set_permissions(&db_path, perms).unwrap();
40756+
40757+ let config = Configuration {
40758+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
40759+ db_path,
40760+ data_path: tmp_dir.path().to_path_buf(),
40761+ administrators: vec![],
40762+ };
40763+ let db = Connection::open_or_create_db(config).unwrap().trusted();
40764+
40765+ let schemas: Vec<String> = {
40766+ let mut stmt = db
40767+ .connection
40768+ .prepare("SELECT value FROM list_settings_json;")
40769+ .unwrap();
40770+ let iter = stmt
40771+ .query_map([], |row| {
40772+ let value: String = row.get("value")?;
40773+ Ok(value)
40774+ })
40775+ .unwrap();
40776+ let mut ret = vec![];
40777+ for item in iter {
40778+ ret.push(item.unwrap());
40779+ }
40780+ ret
40781+ };
40782+ println!("Testing that schemas are valid…");
40783+ for schema in schemas {
40784+ let schema: Value = serde_json::from_str(&schema).unwrap();
40785+ let _compiled = JSONSchema::compile(&schema).expect("A valid schema");
40786+ }
40787+ }
40788 diff --git a/mailpot/tests/smtp.rs b/mailpot/tests/smtp.rs
40789new file mode 100644
40790index 0000000..6fc84d9
40791--- /dev/null
40792+++ b/mailpot/tests/smtp.rs
40793 @@ -0,0 +1,284 @@
40794+ /*
40795+ * This file is part of mailpot
40796+ *
40797+ * Copyright 2020 - Manos Pitsidianakis
40798+ *
40799+ * This program is free software: you can redistribute it and/or modify
40800+ * it under the terms of the GNU Affero General Public License as
40801+ * published by the Free Software Foundation, either version 3 of the
40802+ * License, or (at your option) any later version.
40803+ *
40804+ * This program is distributed in the hope that it will be useful,
40805+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
40806+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
40807+ * GNU Affero General Public License for more details.
40808+ *
40809+ * You should have received a copy of the GNU Affero General Public License
40810+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
40811+ */
40812+
40813+ use log::{trace, warn};
40814+ use mailpot::{melib, models::*, queue::Queue, Configuration, Connection, SendMail};
40815+ use mailpot_tests::*;
40816+ use melib::smol;
40817+ use tempfile::TempDir;
40818+
40819+ #[test]
40820+ fn test_smtp() {
40821+ init_stderr_logging();
40822+
40823+ let tmp_dir = TempDir::new().unwrap();
40824+
40825+ let smtp_handler = TestSmtpHandler::builder().address("127.0.0.1:8825").build();
40826+
40827+ let db_path = tmp_dir.path().join("mpot.db");
40828+ let config = Configuration {
40829+ send_mail: SendMail::Smtp(smtp_handler.smtp_conf()),
40830+ db_path,
40831+ data_path: tmp_dir.path().to_path_buf(),
40832+ administrators: vec![],
40833+ };
40834+
40835+ let db = Connection::open_or_create_db(config).unwrap().trusted();
40836+ assert!(db.lists().unwrap().is_empty());
40837+ let foo_chat = db
40838+ .create_list(MailingList {
40839+ pk: 0,
40840+ name: "foobar chat".into(),
40841+ id: "foo-chat".into(),
40842+ address: "foo-chat@example.com".into(),
40843+ description: None,
40844+ topics: vec![],
40845+ archive_url: None,
40846+ })
40847+ .unwrap();
40848+
40849+ assert_eq!(foo_chat.pk(), 1);
40850+ let post_policy = db
40851+ .set_list_post_policy(PostPolicy {
40852+ pk: 0,
40853+ list: foo_chat.pk(),
40854+ announce_only: false,
40855+ subscription_only: true,
40856+ approval_needed: false,
40857+ open: false,
40858+ custom: false,
40859+ })
40860+ .unwrap();
40861+
40862+ assert_eq!(post_policy.pk(), 1);
40863+
40864+ let input_bytes = include_bytes!("./test_sample_longmessage.eml");
40865+ match melib::Envelope::from_bytes(input_bytes, None) {
40866+ Ok(envelope) => {
40867+ // eprintln!("envelope {:?}", &envelope);
40868+ db.post(&envelope, input_bytes, /* dry_run */ false)
40869+ .expect("Got unexpected error");
40870+ {
40871+ let out = db.queue(Queue::Out).unwrap();
40872+ assert_eq!(out.len(), 1);
40873+ const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
40874+ assert_eq!(
40875+ out[0]
40876+ .comment
40877+ .as_ref()
40878+ .and_then(|c| c.get(..COMMENT_PREFIX.len())),
40879+ Some(COMMENT_PREFIX)
40880+ );
40881+ }
40882+
40883+ db.add_subscription(
40884+ foo_chat.pk(),
40885+ ListSubscription {
40886+ pk: 0,
40887+ list: foo_chat.pk(),
40888+ address: "paaoejunp@example.com".into(),
40889+ name: Some("Cardholder Name".into()),
40890+ account: None,
40891+ digest: false,
40892+ verified: true,
40893+ hide_address: false,
40894+ receive_duplicates: true,
40895+ receive_own_posts: true,
40896+ receive_confirmation: true,
40897+ enabled: true,
40898+ },
40899+ )
40900+ .unwrap();
40901+ db.add_subscription(
40902+ foo_chat.pk(),
40903+ ListSubscription {
40904+ pk: 0,
40905+ list: foo_chat.pk(),
40906+ address: "manos@example.com".into(),
40907+ name: Some("Manos Hands".into()),
40908+ account: None,
40909+ digest: false,
40910+ verified: true,
40911+ hide_address: false,
40912+ receive_duplicates: true,
40913+ receive_own_posts: true,
40914+ receive_confirmation: true,
40915+ enabled: true,
40916+ },
40917+ )
40918+ .unwrap();
40919+ db.post(&envelope, input_bytes, /* dry_run */ false)
40920+ .unwrap();
40921+ }
40922+ Err(err) => {
40923+ panic!("Could not parse message: {}", err);
40924+ }
40925+ }
40926+ let messages = db.delete_from_queue(Queue::Out, vec![]).unwrap();
40927+ eprintln!("Queue out has {} messages.", messages.len());
40928+ let conn_future = db.new_smtp_connection().unwrap();
40929+ smol::future::block_on(smol::spawn(async move {
40930+ let mut conn = conn_future.await.unwrap();
40931+ for msg in messages {
40932+ Connection::submit(&mut conn, &msg, /* dry_run */ false)
40933+ .await
40934+ .unwrap();
40935+ }
40936+ }));
40937+ let stored = smtp_handler.stored.lock().unwrap();
40938+ assert_eq!(stored.len(), 3);
40939+ assert_eq!(&stored[0].0, "paaoejunp@example.com");
40940+ assert_eq!(
40941+ &stored[0].1.subject(),
40942+ "Your post to foo-chat was rejected."
40943+ );
40944+ assert_eq!(
40945+ &stored[1].1.subject(),
40946+ "[foo-chat] thankful that I had the chance to written report, that I could learn and let \
40947+ alone the chance $4454.32"
40948+ );
40949+ assert_eq!(
40950+ &stored[2].1.subject(),
40951+ "[foo-chat] thankful that I had the chance to written report, that I could learn and let \
40952+ alone the chance $4454.32"
40953+ );
40954+ }
40955+
40956+ #[test]
40957+ fn test_smtp_mailcrab() {
40958+ use std::env;
40959+ init_stderr_logging();
40960+
40961+ fn get_smtp_conf() -> melib::smtp::SmtpServerConf {
40962+ use melib::smtp::*;
40963+ SmtpServerConf {
40964+ hostname: "127.0.0.1".into(),
40965+ port: 1025,
40966+ envelope_from: "foo-chat@example.com".into(),
40967+ auth: SmtpAuth::None,
40968+ security: SmtpSecurity::None,
40969+ extensions: Default::default(),
40970+ }
40971+ }
40972+
40973+ let Ok(mailcrab_ip) = env::var("MAILCRAB_IP") else {
40974+ warn!("MAILCRAB_IP env var not set, is mailcrab server running?");
40975+ return;
40976+ };
40977+ let mailcrab_port = env::var("MAILCRAB_PORT").unwrap_or("1080".to_string());
40978+ let api_uri = format!("http://{mailcrab_ip}:{mailcrab_port}/api/messages");
40979+
40980+ let tmp_dir = TempDir::new().unwrap();
40981+
40982+ let db_path = tmp_dir.path().join("mpot.db");
40983+ let config = Configuration {
40984+ send_mail: SendMail::Smtp(get_smtp_conf()),
40985+ db_path,
40986+ data_path: tmp_dir.path().to_path_buf(),
40987+ administrators: vec![],
40988+ };
40989+
40990+ let db = Connection::open_or_create_db(config).unwrap().trusted();
40991+ assert!(db.lists().unwrap().is_empty());
40992+ let foo_chat = db
40993+ .create_list(MailingList {
40994+ pk: 0,
40995+ name: "foobar chat".into(),
40996+ id: "foo-chat".into(),
40997+ address: "foo-chat@example.com".into(),
40998+ description: None,
40999+ topics: vec![],
41000+ archive_url: None,
41001+ })
41002+ .unwrap();
41003+
41004+ assert_eq!(foo_chat.pk(), 1);
41005+ let post_policy = db
41006+ .set_list_post_policy(PostPolicy {
41007+ pk: 0,
41008+ list: foo_chat.pk(),
41009+ announce_only: false,
41010+ subscription_only: true,
41011+ approval_needed: false,
41012+ open: false,
41013+ custom: false,
41014+ })
41015+ .unwrap();
41016+
41017+ assert_eq!(post_policy.pk(), 1);
41018+
41019+ let input_bytes = include_bytes!("./test_sample_longmessage.eml");
41020+ match melib::Envelope::from_bytes(input_bytes, None) {
41021+ Ok(envelope) => {
41022+ match db
41023+ .post(&envelope, input_bytes, /* dry_run */ false)
41024+ .unwrap_err()
41025+ .kind()
41026+ {
41027+ mailpot::ErrorKind::PostRejected(reason) => {
41028+ trace!("Non-subscription post succesfully rejected: '{reason}'");
41029+ }
41030+ other => panic!("Got unexpected error: {}", other),
41031+ }
41032+ db.add_subscription(
41033+ foo_chat.pk(),
41034+ ListSubscription {
41035+ pk: 0,
41036+ list: foo_chat.pk(),
41037+ address: "paaoejunp@example.com".into(),
41038+ name: Some("Cardholder Name".into()),
41039+ account: None,
41040+ digest: false,
41041+ verified: true,
41042+ hide_address: false,
41043+ receive_duplicates: true,
41044+ receive_own_posts: true,
41045+ receive_confirmation: true,
41046+ enabled: true,
41047+ },
41048+ )
41049+ .unwrap();
41050+ db.add_subscription(
41051+ foo_chat.pk(),
41052+ ListSubscription {
41053+ pk: 0,
41054+ list: foo_chat.pk(),
41055+ address: "manos@example.com".into(),
41056+ name: Some("Manos Hands".into()),
41057+ account: None,
41058+ digest: false,
41059+ verified: true,
41060+ hide_address: false,
41061+ receive_duplicates: true,
41062+ receive_own_posts: true,
41063+ receive_confirmation: true,
41064+ enabled: true,
41065+ },
41066+ )
41067+ .unwrap();
41068+ db.post(&envelope, input_bytes, /* dry_run */ false)
41069+ .unwrap();
41070+ }
41071+ Err(err) => {
41072+ panic!("Could not parse message: {}", err);
41073+ }
41074+ }
41075+ let mails: String = reqwest::blocking::get(api_uri).unwrap().text().unwrap();
41076+ trace!("mails: {}", mails);
41077+ }
41078 diff --git a/mailpot/tests/ssh_key b/mailpot/tests/ssh_key
41079new file mode 100644
41080index 0000000..2ddec35
41081--- /dev/null
41082+++ b/mailpot/tests/ssh_key
41083 @@ -0,0 +1,38 @@
41084+ -----BEGIN OPENSSH PRIVATE KEY-----
41085+ b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
41086+ NhAAAAAwEAAQAAAYEA9WwdJs/OhxhDoXqSCJHc3Ywrc3d2ATzfi8OVmlkm3kLSlGIOBefZ
41087+ nWf0ew+mU8tWIg0+U6/skh9tDvZ8jv8V+jsFhlP257eWoMNj6C8rBoXVOr5aUXsvyiboO+
41088+ G9ecu2W9KKDSXlOROA7ucmKx2sUqNdB6HwhnwhiC2Lqzm7utNVc9FLUkyArhW9NbdklsmS
41089+ ocDPzl/WnE3l3xAsaTQTRzWXtXTjit27MqIsh7Ld9q+pqH5DYlam213STE/0Qv4GZdjLTd
41090+ IRoHQ8VLZXsk8ppkRxUCYU4tNIydfwx/RxGG5f8wTbuy096CjJfDcxKsQLPOPPyzhStv3h
41091+ nhHWIP8IIvPXfAUwoTG6o5Z7Czz0kl/CXOATvEStJccj6X13YmaIIDWSmc5JmelDGDj1GR
41092+ 54G3GbimzrCG+nSrhfbwenPSefzcnxPSdROdo7SSt0fgMVxfOi+rVrsr4KWMQUq7e1LYgc
41093+ Wir90g6W4V0S4dRRBnD0A9GuFRcpqPPnz+7oAH3tAAAFiKCeR3ygnkd8AAAAB3NzaC1yc2
41094+ EAAAGBAPVsHSbPzocYQ6F6kgiR3N2MK3N3dgE834vDlZpZJt5C0pRiDgXn2Z1n9HsPplPL
41095+ ViINPlOv7JIfbQ72fI7/Ffo7BYZT9ue3lqDDY+gvKwaF1Tq+WlF7L8om6DvhvXnLtlvSig
41096+ 0l5TkTgO7nJisdrFKjXQeh8IZ8IYgti6s5u7rTVXPRS1JMgK4VvTW3ZJbJkqHAz85f1pxN
41097+ 5d8QLGk0E0c1l7V044rduzKiLIey3favqah+Q2JWpttd0kxP9EL+BmXYy03SEaB0PFS2V7
41098+ JPKaZEcVAmFOLTSMnX8Mf0cRhuX/ME27stPegoyXw3MSrECzzjz8s4Urb94Z4R1iD/CCLz
41099+ 13wFMKExuqOWews89JJfwlzgE7xErSXHI+l9d2JmiCA1kpnOSZnpQxg49RkeeBtxm4ps6w
41100+ hvp0q4X28Hpz0nn83J8T0nUTnaO0krdH4DFcXzovq1a7K+CljEFKu3tS2IHFoq/dIOluFd
41101+ EuHUUQZw9APRrhUXKajz58/u6AB97QAAAAMBAAEAAAGBAJYL13bXLimiSBb93TKoGyTIgf
41102+ hCXT88fF/y4BBR2VWh/SUDHhe2PHHkELD8THCGrM580lJQCI7976tqP5Udl845L5OE2jup
41103+ HsqDKx3VWLTQNiGIJ6gRbJJnXyzdQv6n8YIKIqUPOim/JuDpKYjKx4RupH36IBfY5JdhYT
41104+ b6QTBj7Ka2mxph83p7iAbDbRhTfPav71z5czh018mdFcnsMK0ksvAZ2tQX5E98n0UHsnUT
41105+ yOJe78u7tp//qIdHiss6inRPKsWNkLk9fgzUAAfUu0GmJ5QCfu7RWVO6bXUk3TbgmxO40u
41106+ jmubL97BQTniQqs/BRCYhIDj7bEX9+QB5ck2K9WseD2ODlBW3J87qkVfhix/oP6NES2X2s
41107+ SHfNbDDagrbbweZJ96DXrRPpwV3u0Ez0iDEyxX4c++afT/vMN9kukIEf+GcHoJ2a+jmpZ7
41108+ nDvX4qOBsYQQvaUMBjkaZX8rW/vmRk7ocX6OKZe+h/UjcusyDszxbAcJ+IbpW1bCAk8QAA
41109+ AMEA7WBH3PksQx+8ibGHMstri6XWaB3U10SRm8NjW2CLmIdLPIn2QZ7+jhVLN6Lwj6pAOB
41110+ J2ihYh9CnzKtJA7sPe8EUvoLFSR2eTzxU2blUcDPUF2etUi+6jZsaYIWo/OrFSs28KZaVB
41111+ RsddoQbG2e9xaNWGqBVGogD1dgpAsdUau9kUcKjECxrtuzms97C9856rT9AjI3OroEBaVy
41112+ tivu9JZ30bJE8AYB6+diDJBvFZQM+ihi95n7sZrz8kBXvUiPwhAAAAwQD9NimhT36bbKSx
41113+ k7i6OCSzW079GOgr9YWeX43shEpdENosqwc8SjfuYRTPutvpbAkyeYa6k6QPR1WXWW2dFR
41114+ zslYPxBtUuiTosvOKjCxg2uG/xd68ha/AJRYJMVriMd/vWAy3fKv3k9ZeBLTJsAMfDVtOp
41115+ Q1sbLkUY4KyTeL0oGObzV1rJ8iyA3vJqfA9VolC4T1QI6q2BxPcNOX2r14fYet3a/kSI2+
41116+ aSl7Guonc5V5E716gcuj7w87AXZqDcLDsAAADBAPgf/gfY1rN269TN2CpudEIM4T5c6vl2
41117+ /6E1+49xkUDV6DDllQCM4ZJ7oTzu6hkWOYe9AAqgmkSYq0qGA2JT96Mh5qQSxj51p6z1CI
41118+ udoPxMG7kgQQYcEFiAd7NZEPxGY34pwCG73m9DeJt5hIZR6YQBZVKJsFOrlXAni9ambb2c
41119+ 9YbMSAyFazmpU2uu2X8YRUIjB2C0ggFDUDRilK/ssWxX+HiPU+2woaxemcuK0kWEC02wXo
41120+ bEX7D3T3mJDvVj9wAAAA9lcGlseXNAY29tcG91bmQBAg==
41121+ -----END OPENSSH PRIVATE KEY-----
41122 diff --git a/mailpot/tests/ssh_key.pub b/mailpot/tests/ssh_key.pub
41123new file mode 100644
41124index 0000000..600ab36
41125--- /dev/null
41126+++ b/mailpot/tests/ssh_key.pub
41127 @@ -0,0 +1 @@
41128+ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD1bB0mz86HGEOhepIIkdzdjCtzd3YBPN+Lw5WaWSbeQtKUYg4F59mdZ/R7D6ZTy1YiDT5Tr+ySH20O9nyO/xX6OwWGU/bnt5agw2PoLysGhdU6vlpRey/KJug74b15y7Zb0ooNJeU5E4Du5yYrHaxSo10HofCGfCGILYurObu601Vz0UtSTICuFb01t2SWyZKhwM/OX9acTeXfECxpNBNHNZe1dOOK3bsyoiyHst32r6mofkNiVqbbXdJMT/RC/gZl2MtN0hGgdDxUtleyTymmRHFQJhTi00jJ1/DH9HEYbl/zBNu7LT3oKMl8NzEqxAs848/LOFK2/eGeEdYg/wgi89d8BTChMbqjlnsLPPSSX8Jc4BO8RK0lxyPpfXdiZoggNZKZzkmZ6UMYOPUZHngbcZuKbOsIb6dKuF9vB6c9J5/NyfE9J1E52jtJK3R+AxXF86L6tWuyvgpYxBSrt7UtiBxaKv3SDpbhXRLh1FEGcPQD0a4VFymo8+fP7ugAfe0= epilys@localhost
41129 diff --git a/mailpot/tests/subscription.rs b/mailpot/tests/subscription.rs
41130new file mode 100644
41131index 0000000..1f5468c
41132--- /dev/null
41133+++ b/mailpot/tests/subscription.rs
41134 @@ -0,0 +1,330 @@
41135+ /*
41136+ * This file is part of mailpot
41137+ *
41138+ * Copyright 2020 - Manos Pitsidianakis
41139+ *
41140+ * This program is free software: you can redistribute it and/or modify
41141+ * it under the terms of the GNU Affero General Public License as
41142+ * published by the Free Software Foundation, either version 3 of the
41143+ * License, or (at your option) any later version.
41144+ *
41145+ * This program is distributed in the hope that it will be useful,
41146+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
41147+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
41148+ * GNU Affero General Public License for more details.
41149+ *
41150+ * You should have received a copy of the GNU Affero General Public License
41151+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
41152+ */
41153+
41154+ use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
41155+ use mailpot_tests::init_stderr_logging;
41156+ use serde_json::json;
41157+ use tempfile::TempDir;
41158+
41159+ #[test]
41160+ fn test_list_subscription() {
41161+ init_stderr_logging();
41162+
41163+ let tmp_dir = TempDir::new().unwrap();
41164+
41165+ let db_path = tmp_dir.path().join("mpot.db");
41166+ let config = Configuration {
41167+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
41168+ db_path,
41169+ data_path: tmp_dir.path().to_path_buf(),
41170+ administrators: vec![],
41171+ };
41172+
41173+ let db = Connection::open_or_create_db(config).unwrap().trusted();
41174+ assert!(db.lists().unwrap().is_empty());
41175+ let foo_chat = db
41176+ .create_list(MailingList {
41177+ pk: 0,
41178+ name: "foobar chat".into(),
41179+ id: "foo-chat".into(),
41180+ address: "foo-chat@example.com".into(),
41181+ description: None,
41182+ topics: vec![],
41183+ archive_url: None,
41184+ })
41185+ .unwrap();
41186+
41187+ assert_eq!(foo_chat.pk(), 1);
41188+ let lists = db.lists().unwrap();
41189+ assert_eq!(lists.len(), 1);
41190+ assert_eq!(lists[0], foo_chat);
41191+ let post_policy = db
41192+ .set_list_post_policy(PostPolicy {
41193+ pk: 0,
41194+ list: foo_chat.pk(),
41195+ announce_only: false,
41196+ subscription_only: true,
41197+ approval_needed: false,
41198+ open: false,
41199+ custom: false,
41200+ })
41201+ .unwrap();
41202+
41203+ assert_eq!(post_policy.pk(), 1);
41204+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
41205+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
41206+
41207+ let db = db.untrusted();
41208+
41209+ let post_bytes = b"From: Name <user@example.com>
41210+ To: <foo-chat@example.com>
41211+ Subject: This is a post
41212+ Date: Thu, 29 Oct 2020 13:58:16 +0000
41213+ Message-ID: <abcdefgh@sator.example.com>
41214+ Content-Language: en-US
41215+ Content-Type: text/html
41216+ Content-Transfer-Encoding: base64
41217+ MIME-Version: 1.0
41218+
41219+ PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
41220+ eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
41221+ Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
41222+ eT48L2h0bWw+
41223+ ";
41224+ let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
41225+ db.post(&envelope, post_bytes, /* dry_run */ false)
41226+ .expect("Got unexpected error");
41227+ let out = db.queue(Queue::Out).unwrap();
41228+ assert_eq!(out.len(), 1);
41229+ const COMMENT_PREFIX: &str = "PostAction::Reject { reason: Only subscriptions";
41230+ assert_eq!(
41231+ out[0]
41232+ .comment
41233+ .as_ref()
41234+ .and_then(|c| c.get(..COMMENT_PREFIX.len())),
41235+ Some(COMMENT_PREFIX)
41236+ );
41237+
41238+ let subscribe_bytes = b"From: Name <user@example.com>
41239+ To: <foo-chat+subscribe@example.com>
41240+ Subject: subscribe
41241+ Date: Thu, 29 Oct 2020 13:58:16 +0000
41242+ Message-ID: <abcdefgh@sator.example.com>
41243+ Content-Language: en-US
41244+ Content-Type: text/html
41245+ Content-Transfer-Encoding: base64
41246+ MIME-Version: 1.0
41247+
41248+ ";
41249+ let envelope =
41250+ melib::Envelope::from_bytes(subscribe_bytes, None).expect("Could not parse message");
41251+ db.post(&envelope, subscribe_bytes, /* dry_run */ false)
41252+ .unwrap();
41253+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
41254+ assert_eq!(db.queue(Queue::Out).unwrap().len(), 2);
41255+ let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
41256+ db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
41257+ assert_eq!(db.queue(Queue::Out).unwrap().len(), 2);
41258+ assert_eq!(db.list_posts(foo_chat.pk(), None).unwrap().len(), 1);
41259+ }
41260+
41261+ #[test]
41262+ fn test_post_rejection() {
41263+ init_stderr_logging();
41264+
41265+ const ANNOUNCE_ONLY_PREFIX: Option<&str> =
41266+ Some("PostAction::Reject { reason: You are not allowed to post on this list.");
41267+ const APPROVAL_ONLY_PREFIX: Option<&str> = Some(
41268+ "PostAction::Defer { reason: Your posting has been deferred. Approval from the list's \
41269+ moderators",
41270+ );
41271+
41272+ for (q, mut post_policy) in [
41273+ (
41274+ [(Queue::Out, ANNOUNCE_ONLY_PREFIX)].as_slice(),
41275+ PostPolicy {
41276+ pk: -1,
41277+ list: -1,
41278+ announce_only: true,
41279+ subscription_only: false,
41280+ approval_needed: false,
41281+ open: false,
41282+ custom: false,
41283+ },
41284+ ),
41285+ (
41286+ [(Queue::Out, APPROVAL_ONLY_PREFIX), (Queue::Deferred, None)].as_slice(),
41287+ PostPolicy {
41288+ pk: -1,
41289+ list: -1,
41290+ announce_only: false,
41291+ subscription_only: false,
41292+ approval_needed: true,
41293+ open: false,
41294+ custom: false,
41295+ },
41296+ ),
41297+ ] {
41298+ let tmp_dir = TempDir::new().unwrap();
41299+
41300+ let db_path = tmp_dir.path().join("mpot.db");
41301+ let config = Configuration {
41302+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
41303+ db_path,
41304+ data_path: tmp_dir.path().to_path_buf(),
41305+ administrators: vec![],
41306+ };
41307+
41308+ let db = Connection::open_or_create_db(config).unwrap().trusted();
41309+ assert!(db.lists().unwrap().is_empty());
41310+ let foo_chat = db
41311+ .create_list(MailingList {
41312+ pk: 0,
41313+ name: "foobar chat".into(),
41314+ id: "foo-chat".into(),
41315+ address: "foo-chat@example.com".into(),
41316+ description: None,
41317+ topics: vec![],
41318+ archive_url: None,
41319+ })
41320+ .unwrap();
41321+
41322+ assert_eq!(foo_chat.pk(), 1);
41323+ let lists = db.lists().unwrap();
41324+ assert_eq!(lists.len(), 1);
41325+ assert_eq!(lists[0], foo_chat);
41326+ post_policy.list = foo_chat.pk();
41327+ let post_policy = db.set_list_post_policy(post_policy).unwrap();
41328+
41329+ assert_eq!(post_policy.pk(), 1);
41330+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
41331+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
41332+
41333+ let db = db.untrusted();
41334+
41335+ let post_bytes = b"From: Name <user@example.com>
41336+ To: <foo-chat@example.com>
41337+ Subject: This is a post
41338+ Date: Thu, 29 Oct 2020 13:58:16 +0000
41339+ Message-ID: <abcdefgh@sator.example.com>
41340+ Content-Language: en-US
41341+ Content-Type: text/html
41342+ Content-Transfer-Encoding: base64
41343+ MIME-Version: 1.0
41344+
41345+ PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
41346+ eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
41347+ Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
41348+ eT48L2h0bWw+
41349+ ";
41350+ let envelope =
41351+ melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
41352+ db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
41353+ for &(q, prefix) in q {
41354+ let q = db.queue(q).unwrap();
41355+ assert_eq!(q.len(), 1);
41356+ if let Some(prefix) = prefix {
41357+ assert_eq!(
41358+ q[0].comment.as_ref().and_then(|c| c.get(..prefix.len())),
41359+ Some(prefix)
41360+ );
41361+ }
41362+ }
41363+ }
41364+ }
41365+
41366+ #[test]
41367+ fn test_post_filters() {
41368+ init_stderr_logging();
41369+ let tmp_dir = TempDir::new().unwrap();
41370+
41371+ let mut post_policy = PostPolicy {
41372+ pk: -1,
41373+ list: -1,
41374+ announce_only: false,
41375+ subscription_only: false,
41376+ approval_needed: false,
41377+ open: true,
41378+ custom: false,
41379+ };
41380+ let db_path = tmp_dir.path().join("mpot.db");
41381+ let config = Configuration {
41382+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
41383+ db_path,
41384+ data_path: tmp_dir.path().to_path_buf(),
41385+ administrators: vec![],
41386+ };
41387+
41388+ let db = Connection::open_or_create_db(config).unwrap().trusted();
41389+ let foo_chat = db
41390+ .create_list(MailingList {
41391+ pk: 0,
41392+ name: "foobar chat".into(),
41393+ id: "foo-chat".into(),
41394+ address: "foo-chat@example.com".into(),
41395+ description: None,
41396+ topics: vec![],
41397+ archive_url: None,
41398+ })
41399+ .unwrap();
41400+ post_policy.list = foo_chat.pk();
41401+ db.add_subscription(
41402+ foo_chat.pk(),
41403+ ListSubscription {
41404+ pk: -1,
41405+ list: foo_chat.pk(),
41406+ address: "user@example.com".into(),
41407+ name: None,
41408+ account: None,
41409+ digest: false,
41410+ enabled: true,
41411+ verified: true,
41412+ hide_address: false,
41413+ receive_duplicates: true,
41414+ receive_own_posts: true,
41415+ receive_confirmation: false,
41416+ },
41417+ )
41418+ .unwrap();
41419+ db.set_list_post_policy(post_policy).unwrap();
41420+
41421+ let post_bytes = b"From: Name <user@example.com>
41422+ To: <foo-chat@example.com>
41423+ Subject: This is a post
41424+ Date: Thu, 29 Oct 2020 13:58:16 +0000
41425+ Message-ID: <abcdefgh@sator.example.com>
41426+ Content-Language: en-US
41427+ Content-Type: text/html
41428+ Content-Transfer-Encoding: base64
41429+ MIME-Version: 1.0
41430+
41431+ PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
41432+ eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
41433+ Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
41434+ eT48L2h0bWw+
41435+ ";
41436+ let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
41437+ db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
41438+ let q = db.queue(Queue::Out).unwrap();
41439+ assert_eq!(&q[0].subject, "[foo-chat] This is a post");
41440+
41441+ db.delete_from_queue(Queue::Out, vec![]).unwrap();
41442+ {
41443+ let mut stmt = db
41444+ .connection
41445+ .prepare(
41446+ "INSERT INTO list_settings_json(name, list, value) \
41447+ VALUES('AddSubjectTagPrefixSettings', ?, ?) RETURNING *;",
41448+ )
41449+ .unwrap();
41450+ stmt.query_row(
41451+ rusqlite::params![
41452+ &foo_chat.pk(),
41453+ &json!({
41454+ "enabled": false
41455+ }),
41456+ ],
41457+ |_| Ok(()),
41458+ )
41459+ .unwrap();
41460+ }
41461+ db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
41462+ let q = db.queue(Queue::Out).unwrap();
41463+ assert_eq!(&q[0].subject, "This is a post");
41464+ }
41465 diff --git a/mailpot/tests/template_replies.rs b/mailpot/tests/template_replies.rs
41466new file mode 100644
41467index 0000000..8648b2e
41468--- /dev/null
41469+++ b/mailpot/tests/template_replies.rs
41470 @@ -0,0 +1,236 @@
41471+ /*
41472+ * This file is part of mailpot
41473+ *
41474+ * Copyright 2020 - Manos Pitsidianakis
41475+ *
41476+ * This program is free software: you can redistribute it and/or modify
41477+ * it under the terms of the GNU Affero General Public License as
41478+ * published by the Free Software Foundation, either version 3 of the
41479+ * License, or (at your option) any later version.
41480+ *
41481+ * This program is distributed in the hope that it will be useful,
41482+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
41483+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
41484+ * GNU Affero General Public License for more details.
41485+ *
41486+ * You should have received a copy of the GNU Affero General Public License
41487+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
41488+ */
41489+
41490+ use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail, Template};
41491+ use mailpot_tests::init_stderr_logging;
41492+ use tempfile::TempDir;
41493+
41494+ #[test]
41495+ fn test_template_replies() {
41496+ init_stderr_logging();
41497+
41498+ const SUB_BYTES: &[u8] = b"From: Name <user@example.com>
41499+ To: <foo-chat+subscribe@example.com>
41500+ Subject: subscribe
41501+ Date: Thu, 29 Oct 2020 13:58:16 +0000
41502+ Message-ID: <abcdefgh@sator.example.com>
41503+ Content-Language: en-US
41504+ Content-Type: text/html
41505+ Content-Transfer-Encoding: base64
41506+ MIME-Version: 1.0
41507+
41508+ ";
41509+ const UNSUB_BYTES: &[u8] = b"From: Name <user@example.com>
41510+ To: <foo-chat+request@example.com>
41511+ Subject: unsubscribe
41512+ Date: Thu, 29 Oct 2020 13:58:17 +0000
41513+ Message-ID: <abcdefgh@sator.example.com>
41514+ Content-Language: en-US
41515+ Content-Type: text/html
41516+ Content-Transfer-Encoding: base64
41517+ MIME-Version: 1.0
41518+
41519+ ";
41520+
41521+ let tmp_dir = TempDir::new().unwrap();
41522+
41523+ let db_path = tmp_dir.path().join("mpot.db");
41524+ let config = Configuration {
41525+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
41526+ db_path,
41527+ data_path: tmp_dir.path().to_path_buf(),
41528+ administrators: vec![],
41529+ };
41530+
41531+ let mut db = Connection::open_or_create_db(config).unwrap().trusted();
41532+ assert!(db.lists().unwrap().is_empty());
41533+ let foo_chat = db
41534+ .create_list(MailingList {
41535+ pk: 0,
41536+ name: "foobar chat".into(),
41537+ id: "foo-chat".into(),
41538+ address: "foo-chat@example.com".into(),
41539+ description: None,
41540+ topics: vec![],
41541+ archive_url: None,
41542+ })
41543+ .unwrap();
41544+
41545+ assert_eq!(foo_chat.pk(), 1);
41546+ let lists = db.lists().unwrap();
41547+ assert_eq!(lists.len(), 1);
41548+ assert_eq!(lists[0], foo_chat);
41549+ let post_policy = db
41550+ .set_list_post_policy(PostPolicy {
41551+ pk: 0,
41552+ list: foo_chat.pk(),
41553+ announce_only: false,
41554+ subscription_only: true,
41555+ approval_needed: false,
41556+ open: false,
41557+ custom: false,
41558+ })
41559+ .unwrap();
41560+
41561+ assert_eq!(post_policy.pk(), 1);
41562+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
41563+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
41564+
41565+ let _templ_gen = db
41566+ .add_template(Template {
41567+ pk: -1,
41568+ name: Template::SUBSCRIPTION_CONFIRMATION.into(),
41569+ list: None,
41570+ subject: Some("You have subscribed to a list".into()),
41571+ headers_json: None,
41572+ body: "You have subscribed to a list".into(),
41573+ })
41574+ .unwrap();
41575+ /* create custom subscribe confirm template, and check that it is used in
41576+ * action */
41577+ let _templ = db
41578+ .add_template(Template {
41579+ pk: -1,
41580+ name: Template::SUBSCRIPTION_CONFIRMATION.into(),
41581+ list: Some(foo_chat.pk()),
41582+ subject: Some("You have subscribed to {{ list.name }}".into()),
41583+ headers_json: None,
41584+ body: "You have subscribed to {{ list.name }}".into(),
41585+ })
41586+ .unwrap();
41587+ let _all = db.fetch_templates().unwrap();
41588+ assert_eq!(&_all[0], &_templ_gen);
41589+ assert_eq!(&_all[1], &_templ);
41590+ assert_eq!(_all.len(), 2);
41591+
41592+ let sub_fn = |db: &mut Connection| {
41593+ let subenvelope =
41594+ melib::Envelope::from_bytes(SUB_BYTES, None).expect("Could not parse message");
41595+ db.post(&subenvelope, SUB_BYTES, /* dry_run */ false)
41596+ .unwrap();
41597+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 1);
41598+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
41599+ };
41600+ let unsub_fn = |db: &mut Connection| {
41601+ let envelope =
41602+ melib::Envelope::from_bytes(UNSUB_BYTES, None).expect("Could not parse message");
41603+ db.post(&envelope, UNSUB_BYTES, /* dry_run */ false)
41604+ .unwrap();
41605+ assert_eq!(db.list_subscriptions(foo_chat.pk()).unwrap().len(), 0);
41606+ assert_eq!(db.queue(Queue::Error).unwrap().len(), 0);
41607+ };
41608+
41609+ /* subscribe first */
41610+
41611+ sub_fn(&mut db);
41612+
41613+ let out_queue = db.queue(Queue::Out).unwrap();
41614+ assert_eq!(out_queue.len(), 1);
41615+ let out = &out_queue[0];
41616+ let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
41617+
41618+ assert_eq!(
41619+ &out_env.from()[0].get_email(),
41620+ "foo-chat+request@example.com",
41621+ );
41622+ assert_eq!(
41623+ (
41624+ out_env.to()[0].get_display_name().as_deref(),
41625+ out_env.to()[0].get_email().as_str()
41626+ ),
41627+ (Some("Name"), "user@example.com"),
41628+ );
41629+ assert_eq!(
41630+ &out.subject,
41631+ &format!("You have subscribed to {}", foo_chat.name)
41632+ );
41633+
41634+ /* then unsubscribe, remove custom template and subscribe again */
41635+
41636+ unsub_fn(&mut db);
41637+
41638+ let out_queue = db.queue(Queue::Out).unwrap();
41639+ assert_eq!(out_queue.len(), 2);
41640+
41641+ let mut _templ = _templ.into_inner();
41642+ let _templ2 = db
41643+ .remove_template(Template::SUBSCRIPTION_CONFIRMATION, Some(foo_chat.pk()))
41644+ .unwrap();
41645+ _templ.pk = _templ2.pk;
41646+ assert_eq!(_templ, _templ2);
41647+
41648+ /* now the first inserted template should be used: */
41649+
41650+ sub_fn(&mut db);
41651+
41652+ let out_queue = db.queue(Queue::Out).unwrap();
41653+
41654+ assert_eq!(out_queue.len(), 3);
41655+ let out = &out_queue[2];
41656+ let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
41657+
41658+ assert_eq!(
41659+ &out_env.from()[0].get_email(),
41660+ "foo-chat+request@example.com",
41661+ );
41662+ assert_eq!(
41663+ (
41664+ out_env.to()[0].get_display_name().as_deref(),
41665+ out_env.to()[0].get_email().as_str()
41666+ ),
41667+ (Some("Name"), "user@example.com"),
41668+ );
41669+ assert_eq!(&out.subject, "You have subscribed to a list");
41670+
41671+ unsub_fn(&mut db);
41672+ let mut _templ_gen_2 = db
41673+ .remove_template(Template::SUBSCRIPTION_CONFIRMATION, None)
41674+ .unwrap();
41675+ _templ_gen_2.pk = _templ_gen.pk;
41676+ assert_eq!(_templ_gen_2, _templ_gen.into_inner());
41677+
41678+ /* now this template should be used: */
41679+
41680+ sub_fn(&mut db);
41681+
41682+ let out_queue = db.queue(Queue::Out).unwrap();
41683+
41684+ assert_eq!(out_queue.len(), 5);
41685+ let out = &out_queue[4];
41686+ let out_env = melib::Envelope::from_bytes(&out.message, None).unwrap();
41687+
41688+ assert_eq!(
41689+ &out_env.from()[0].get_email(),
41690+ "foo-chat+request@example.com",
41691+ );
41692+ assert_eq!(
41693+ (
41694+ out_env.to()[0].get_display_name().as_deref(),
41695+ out_env.to()[0].get_email().as_str()
41696+ ),
41697+ (Some("Name"), "user@example.com"),
41698+ );
41699+ assert_eq!(
41700+ &out.subject,
41701+ &format!(
41702+ "[{}] You have successfully subscribed to {}.",
41703+ foo_chat.id, foo_chat.name
41704+ )
41705+ );
41706+ }
41707 diff --git a/mailpot/tests/test_sample_longmessage.eml b/mailpot/tests/test_sample_longmessage.eml
41708new file mode 100644
41709index 0000000..a41ff28
41710--- /dev/null
41711+++ b/mailpot/tests/test_sample_longmessage.eml
41712 @@ -0,0 +1,25 @@
41713+ Return-Path: <paaoejunp@example.com>
41714+ Delivered-To: john@example.com
41715+ Received: from violet.example.com
41716+ by violet.example.com with LMTP
41717+ id qBHcI7LKml9FxzIAYrQLqw
41718+ (envelope-from <paaoejunp@example.com>)
41719+ for <john@example.com>; Thu, 29 Oct 2020 13:59:14 +0000
41720+ Return-path: <paaoejunp@example.com>
41721+ Envelope-to: john@example.com
41722+ Delivery-date: Thu, 29 Oct 2020 13:59:14 +0000
41723+ From: Cardholder Name <paaoejunp@example.com>
41724+ To: <foo-chat@example.com>
41725+ Subject: thankful that I had the chance to written report, that I could learn
41726+ and let alone the chance $4454.32
41727+ Date: Thu, 29 Oct 2020 13:58:16 +0000
41728+ Message-ID: <abcdefgh@sator.example.com>
41729+ Content-Language: en-US
41730+ Content-Type: text/html
41731+ Content-Transfer-Encoding: base64
41732+ MIME-Version: 1.0
41733+
41734+ PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
41735+ eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
41736+ Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
41737+ eT48L2h0bWw+
41738 diff --git a/mailpot/tools/generate_configuration_json_schema.py b/mailpot/tools/generate_configuration_json_schema.py
41739new file mode 100755
41740index 0000000..e12fae1
41741--- /dev/null
41742+++ b/mailpot/tools/generate_configuration_json_schema.py
41743 @@ -0,0 +1,52 @@
41744+ #!/usr/bin/env python3
41745+ """
41746+ Example taken from https://jcristharif.com/msgspec/jsonschema.html
41747+ """
41748+ import msgspec
41749+ from msgspec import Struct, Meta
41750+ from typing import Annotated, Optional
41751+
41752+ Template = Annotated[
41753+ str,
41754+ Meta(
41755+ pattern=".+[{]msg-id[}].*",
41756+ description="""Template for \
41757+ `Archived-At` header value, as described in RFC 5064 "The Archived-At \
41758+ Message Header Field". The template receives only one string variable \
41759+ with the value of the mailing list post `Message-ID` header.
41760+
41761+ For example, if:
41762+
41763+ - the template is `http://www.example.com/mid/{msg-id}`
41764+ - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`
41765+
41766+ The full header will be generated as:
41767+
41768+ `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>
41769+
41770+ Note: Surrounding carets in the `Message-ID` value are not required. If \
41771+ you wish to preserve them in the URL, set option `preserve-carets` to \
41772+ true.""",
41773+ title="Jinja template for header value",
41774+ examples=[
41775+ "https://www.example.com/{msg-id}",
41776+ "https://www.example.com/{msg-id}.html",
41777+ ],
41778+ ),
41779+ ]
41780+
41781+ PreserveCarets = Annotated[
41782+ bool, Meta(title="Preserve carets of `Message-ID` in generated value")
41783+ ]
41784+
41785+
41786+ class ArchivedAtLinkSettings(Struct):
41787+ """Settings for ArchivedAtLink message filter"""
41788+
41789+ template: Template
41790+ preserve_carets: PreserveCarets = False
41791+
41792+
41793+ schema = {"$schema": "http://json-schema.org/draft-07/schema"}
41794+ schema.update(msgspec.json.schema(ArchivedAtLinkSettings))
41795+ print(msgspec.json.format(msgspec.json.encode(schema)).decode("utf-8"))
41796 diff --git a/rest-http/.gitignore b/rest-http/.gitignore
41797deleted file mode 100644
41798index 856c436..0000000
41799--- a/rest-http/.gitignore
41800+++ /dev/null
41801 @@ -1,2 +0,0 @@
41802- .env
41803- config/local.json
41804 diff --git a/rest-http/Cargo.toml b/rest-http/Cargo.toml
41805deleted file mode 100644
41806index b643f59..0000000
41807--- a/rest-http/Cargo.toml
41808+++ /dev/null
41809 @@ -1,49 +0,0 @@
41810- [package]
41811- name = "mailpot-http"
41812- version = "0.1.1"
41813- authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
41814- edition = "2021"
41815- license = "LICENSE"
41816- readme = "README.md"
41817- description = "mailing list manager"
41818- repository = "https://github.com/meli/mailpot"
41819- keywords = ["mail", "mailing-lists"]
41820- categories = ["email"]
41821- default-run = "mpot-http"
41822-
41823- [[bin]]
41824- name = "mpot-http"
41825- path = "src/main.rs"
41826-
41827- [dependencies]
41828- async-trait = "0.1"
41829- axum = { version = "0.6", features = ["headers"] }
41830- axum-extra = { version = "^0.7", features = ["typed-routing"] }
41831- #jsonwebtoken = "8.3"
41832- bcrypt = "0.14"
41833- config = "0.13"
41834- http = "0.2"
41835- lazy_static = "1.4"
41836- log = "0.4"
41837- mailpot = { version = "^0.1", path = "../core" }
41838- mailpot-web = { version = "^0.1", path = "../web" }
41839- serde = { version = "1", features = ["derive"] }
41840- serde_json = "1"
41841- stderrlog = { version = "^0.6" }
41842- thiserror = "1"
41843- tokio = { version = "1", features = ["full"] }
41844- tower-http = { version = "0.4", features = [
41845- "trace",
41846- "compression-br",
41847- "propagate-header",
41848- "sensitive-headers",
41849- "cors",
41850- ] }
41851-
41852- [dev-dependencies]
41853- assert-json-diff = "2"
41854- hyper = { version = "0.14" }
41855- mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
41856- reqwest = { version = "0.11", features = ["json"] }
41857- tempfile = { version = "3.9" }
41858- tower = { version = "^0.4" }
41859 diff --git a/rest-http/README.md b/rest-http/README.md
41860deleted file mode 100644
41861index a89e59d..0000000
41862--- a/rest-http/README.md
41863+++ /dev/null
41864 @@ -1,2 +0,0 @@
41865- # mailpot REST http server
41866-
41867 diff --git a/rest-http/config/default.json b/rest-http/config/default.json
41868deleted file mode 100644
41869index fba51c5..0000000
41870--- a/rest-http/config/default.json
41871+++ /dev/null
41872 @@ -1,12 +0,0 @@
41873- {
41874- "environment": "development",
41875- "server": {
41876- "port": 8080
41877- },
41878- "auth": {
41879- "secret": "secret"
41880- },
41881- "logger": {
41882- "level": "debug"
41883- }
41884- }
41885 diff --git a/rest-http/config/production.json b/rest-http/config/production.json
41886deleted file mode 100644
41887index 0b731fa..0000000
41888--- a/rest-http/config/production.json
41889+++ /dev/null
41890 @@ -1,6 +0,0 @@
41891- {
41892- "environment": "production",
41893- "logger": {
41894- "level": "info"
41895- }
41896- }
41897 diff --git a/rest-http/config/test.json b/rest-http/config/test.json
41898deleted file mode 100644
41899index a162f57..0000000
41900--- a/rest-http/config/test.json
41901+++ /dev/null
41902 @@ -1,9 +0,0 @@
41903- {
41904- "environment": "test",
41905- "server": {
41906- "port": 8088
41907- },
41908- "logger": {
41909- "level": "error"
41910- }
41911- }
41912 diff --git a/rest-http/rustfmt.toml b/rest-http/rustfmt.toml
41913deleted file mode 120000
41914index 39f97b0..0000000
41915--- a/rest-http/rustfmt.toml
41916+++ /dev/null
41917 @@ -1 +0,0 @@
41918- ../rustfmt.toml
41919\ No newline at end of file
41920 diff --git a/rest-http/src/errors.rs b/rest-http/src/errors.rs
41921deleted file mode 100644
41922index 7d78020..0000000
41923--- a/rest-http/src/errors.rs
41924+++ /dev/null
41925 @@ -1,98 +0,0 @@
41926- use axum::{
41927- http::StatusCode,
41928- response::{IntoResponse, Response},
41929- Json,
41930- };
41931- use bcrypt::BcryptError;
41932- use serde_json::json;
41933- use tokio::task::JoinError;
41934-
41935- #[derive(thiserror::Error, Debug)]
41936- #[error("...")]
41937- pub enum Error {
41938- #[error("Error parsing ObjectID {0}")]
41939- ParseObjectID(String),
41940-
41941- #[error("{0}")]
41942- Authenticate(#[from] AuthenticateError),
41943-
41944- #[error("{0}")]
41945- BadRequest(#[from] BadRequest),
41946-
41947- #[error("{0}")]
41948- NotFound(#[from] NotFound),
41949-
41950- #[error("{0}")]
41951- RunSyncTask(#[from] JoinError),
41952-
41953- #[error("{0}")]
41954- HashPassword(#[from] BcryptError),
41955-
41956- #[error("{0}")]
41957- System(#[from] mailpot::Error),
41958- }
41959-
41960- impl Error {
41961- fn get_codes(&self) -> (StatusCode, u16) {
41962- match *self {
41963- // 4XX Errors
41964- Error::ParseObjectID(_) => (StatusCode::BAD_REQUEST, 40001),
41965- Error::BadRequest(_) => (StatusCode::BAD_REQUEST, 40002),
41966- Error::NotFound(_) => (StatusCode::NOT_FOUND, 40003),
41967- Error::Authenticate(AuthenticateError::WrongCredentials) => {
41968- (StatusCode::UNAUTHORIZED, 40004)
41969- }
41970- Error::Authenticate(AuthenticateError::InvalidToken) => {
41971- (StatusCode::UNAUTHORIZED, 40005)
41972- }
41973- Error::Authenticate(AuthenticateError::Locked) => (StatusCode::LOCKED, 40006),
41974-
41975- // 5XX Errors
41976- Error::Authenticate(AuthenticateError::TokenCreation) => {
41977- (StatusCode::INTERNAL_SERVER_ERROR, 5001)
41978- }
41979- Error::RunSyncTask(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5005),
41980- Error::HashPassword(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5006),
41981- Error::System(_) => (StatusCode::INTERNAL_SERVER_ERROR, 5007),
41982- }
41983- }
41984-
41985- pub fn bad_request() -> Self {
41986- Error::BadRequest(BadRequest {})
41987- }
41988-
41989- pub fn not_found() -> Self {
41990- Error::NotFound(NotFound {})
41991- }
41992- }
41993-
41994- impl IntoResponse for Error {
41995- fn into_response(self) -> Response {
41996- let (status_code, code) = self.get_codes();
41997- let message = self.to_string();
41998- let body = Json(json!({ "code": code, "message": message }));
41999-
42000- (status_code, body).into_response()
42001- }
42002- }
42003-
42004- #[derive(thiserror::Error, Debug)]
42005- #[error("...")]
42006- pub enum AuthenticateError {
42007- #[error("Wrong authentication credentials")]
42008- WrongCredentials,
42009- #[error("Failed to create authentication token")]
42010- TokenCreation,
42011- #[error("Invalid authentication credentials")]
42012- InvalidToken,
42013- #[error("User is locked")]
42014- Locked,
42015- }
42016-
42017- #[derive(thiserror::Error, Debug)]
42018- #[error("Bad Request")]
42019- pub struct BadRequest {}
42020-
42021- #[derive(thiserror::Error, Debug)]
42022- #[error("Not found")]
42023- pub struct NotFound {}
42024 diff --git a/rest-http/src/lib.rs b/rest-http/src/lib.rs
42025deleted file mode 100644
42026index 3dd161a..0000000
42027--- a/rest-http/src/lib.rs
42028+++ /dev/null
42029 @@ -1,51 +0,0 @@
42030- /*
42031- * This file is part of mailpot
42032- *
42033- * Copyright 2020 - Manos Pitsidianakis
42034- *
42035- * This program is free software: you can redistribute it and/or modify
42036- * it under the terms of the GNU Affero General Public License as
42037- * published by the Free Software Foundation, either version 3 of the
42038- * License, or (at your option) any later version.
42039- *
42040- * This program is distributed in the hope that it will be useful,
42041- * but WITHOUT ANY WARRANTY; without even the implied warranty of
42042- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
42043- * GNU Affero General Public License for more details.
42044- *
42045- * You should have received a copy of the GNU Affero General Public License
42046- * along with this program. If not, see <https://www.gnu.org/licenses/>.
42047- */
42048-
42049- pub use std::{net::SocketAddr, sync::Arc};
42050-
42051- pub use axum::Router;
42052- pub use http::header;
42053- pub use log::{debug, info, trace};
42054- pub use mailpot::{models::*, Configuration, Connection};
42055- pub mod errors;
42056- pub mod routes;
42057- pub mod settings;
42058-
42059- use tower_http::{
42060- compression::CompressionLayer, cors::CorsLayer, propagate_header::PropagateHeaderLayer,
42061- sensitive_headers::SetSensitiveHeadersLayer,
42062- };
42063-
42064- pub fn create_app(conf: Arc<Configuration>) -> Router {
42065- Router::new()
42066- .with_state(conf.clone())
42067- .merge(Router::new().nest("/v1", Router::new().merge(routes::list::create_route(conf))))
42068- .layer(SetSensitiveHeadersLayer::new(std::iter::once(
42069- header::AUTHORIZATION,
42070- )))
42071- // Compress responses
42072- .layer(CompressionLayer::new())
42073- // Propagate `X-Request-Id`s from requests to responses
42074- .layer(PropagateHeaderLayer::new(header::HeaderName::from_static(
42075- "x-request-id",
42076- )))
42077- // CORS configuration. This should probably be more restrictive in
42078- // production.
42079- .layer(CorsLayer::permissive())
42080- }
42081 diff --git a/rest-http/src/main.rs b/rest-http/src/main.rs
42082deleted file mode 100644
42083index 704e406..0000000
42084--- a/rest-http/src/main.rs
42085+++ /dev/null
42086 @@ -1,32 +0,0 @@
42087- use mailpot_http::{settings::SETTINGS, *};
42088-
42089- use crate::create_app;
42090-
42091- #[tokio::main]
42092- async fn main() {
42093- let config_path = std::env::args()
42094- .nth(1)
42095- .expect("Expected configuration file path as first argument.");
42096- #[cfg(test)]
42097- let verbosity = log::LevelFilter::Trace;
42098- #[cfg(not(test))]
42099- let verbosity = log::LevelFilter::Info;
42100- stderrlog::new()
42101- .quiet(false)
42102- .verbosity(verbosity)
42103- .show_module_names(true)
42104- .timestamp(stderrlog::Timestamp::Millisecond)
42105- .init()
42106- .unwrap();
42107- let conf = Arc::new(Configuration::from_file(config_path).unwrap());
42108- let app = create_app(conf);
42109-
42110- let port = SETTINGS.server.port;
42111- let address = SocketAddr::from(([127, 0, 0, 1], port));
42112-
42113- info!("Server listening on {}", &address);
42114- axum::Server::bind(&address)
42115- .serve(app.into_make_service())
42116- .await
42117- .expect("Failed to start server");
42118- }
42119 diff --git a/rest-http/src/routes/list.rs b/rest-http/src/routes/list.rs
42120deleted file mode 100644
42121index 7fdfaad..0000000
42122--- a/rest-http/src/routes/list.rs
42123+++ /dev/null
42124 @@ -1,417 +0,0 @@
42125- use std::sync::Arc;
42126-
42127- pub use axum::extract::{Path, Query, State};
42128- use axum::{http::StatusCode, Json, Router};
42129- use mailpot_web::{typed_paths::*, ResponseError, RouterExt, TypedPath};
42130- use serde::{Deserialize, Serialize};
42131-
42132- use crate::*;
42133-
42134- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
42135- #[typed_path("/list/")]
42136- pub struct ListsPath;
42137-
42138- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
42139- #[typed_path("/list/:id/owner/")]
42140- pub struct ListOwnerPath(pub ListPathIdentifier);
42141-
42142- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
42143- #[typed_path("/list/:id/subscription/")]
42144- pub struct ListSubscriptionPath(pub ListPathIdentifier);
42145-
42146- pub fn create_route(conf: Arc<Configuration>) -> Router {
42147- Router::new()
42148- .typed_get(all_lists)
42149- .typed_post(new_list)
42150- .typed_get(get_list)
42151- .typed_post({
42152- move |_: ListPath| async move {
42153- Err::<(), ResponseError>(mailpot_web::ResponseError::new(
42154- "Invalid method".to_string(),
42155- StatusCode::BAD_REQUEST,
42156- ))
42157- }
42158- })
42159- .typed_get(get_list_owner)
42160- .typed_post(new_list_owner)
42161- .typed_get(get_list_subs)
42162- .typed_post(new_list_sub)
42163- .with_state(conf)
42164- }
42165-
42166- async fn get_list(
42167- ListPath(id): ListPath,
42168- State(state): State<Arc<Configuration>>,
42169- ) -> Result<Json<MailingList>, ResponseError> {
42170- let db = Connection::open_db(Configuration::clone(&state))?;
42171- let Some(list) = (match id {
42172- ListPathIdentifier::Pk(id) => db.list(id)?,
42173- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
42174- }) else {
42175- return Err(mailpot_web::ResponseError::new(
42176- "Not found".to_string(),
42177- StatusCode::NOT_FOUND,
42178- ));
42179- };
42180- Ok(Json(list.into_inner()))
42181- }
42182-
42183- async fn all_lists(
42184- _: ListsPath,
42185- Query(GetRequest {
42186- filter: _,
42187- count,
42188- page,
42189- }): Query<GetRequest>,
42190- State(state): State<Arc<Configuration>>,
42191- ) -> Result<Json<GetResponse>, ResponseError> {
42192- let db = Connection::open_db(Configuration::clone(&state))?;
42193- let lists_values = db.lists()?;
42194- let page = page.unwrap_or(0);
42195- let Some(count) = count else {
42196- let mut stmt = db.connection.prepare("SELECT count(*) FROM list;")?;
42197- return Ok(Json(GetResponse {
42198- entries: vec![],
42199- total: stmt.query_row([], |row| {
42200- let count: usize = row.get(0)?;
42201- Ok(count)
42202- })?,
42203- start: 0,
42204- }));
42205- };
42206- let offset = page * count;
42207- let res: Vec<_> = lists_values
42208- .into_iter()
42209- .skip(offset)
42210- .take(count)
42211- .map(DbVal::into_inner)
42212- .collect();
42213-
42214- Ok(Json(GetResponse {
42215- total: res.len(),
42216- start: offset,
42217- entries: res,
42218- }))
42219- }
42220-
42221- async fn new_list(
42222- _: ListsPath,
42223- State(_state): State<Arc<Configuration>>,
42224- //Json(_body): Json<GetRequest>,
42225- ) -> Result<Json<()>, ResponseError> {
42226- // TODO create new list
42227- Err(mailpot_web::ResponseError::new(
42228- "Not allowed".to_string(),
42229- StatusCode::UNAUTHORIZED,
42230- ))
42231- }
42232-
42233- #[derive(Debug, Serialize, Deserialize)]
42234- enum GetFilter {
42235- Pk(i64),
42236- Address(String),
42237- Id(String),
42238- Name(String),
42239- }
42240-
42241- #[derive(Debug, Serialize, Deserialize)]
42242- struct GetRequest {
42243- filter: Option<GetFilter>,
42244- count: Option<usize>,
42245- page: Option<usize>,
42246- }
42247-
42248- #[derive(Debug, Serialize, Deserialize)]
42249- struct GetResponse {
42250- entries: Vec<MailingList>,
42251- total: usize,
42252- start: usize,
42253- }
42254-
42255- async fn get_list_owner(
42256- ListOwnerPath(id): ListOwnerPath,
42257- State(state): State<Arc<Configuration>>,
42258- ) -> Result<Json<Vec<ListOwner>>, ResponseError> {
42259- let db = Connection::open_db(Configuration::clone(&state))?;
42260- let owners = match id {
42261- ListPathIdentifier::Pk(id) => db.list_owners(id)?,
42262- ListPathIdentifier::Id(id) => {
42263- if let Some(owners) = db.list_by_id(id)?.map(|l| db.list_owners(l.pk())) {
42264- owners?
42265- } else {
42266- return Err(mailpot_web::ResponseError::new(
42267- "Not found".to_string(),
42268- StatusCode::NOT_FOUND,
42269- ));
42270- }
42271- }
42272- };
42273- Ok(Json(owners.into_iter().map(DbVal::into_inner).collect()))
42274- }
42275-
42276- async fn new_list_owner(
42277- ListOwnerPath(_id): ListOwnerPath,
42278- State(_state): State<Arc<Configuration>>,
42279- //Json(_body): Json<GetRequest>,
42280- ) -> Result<Json<Vec<ListOwner>>, ResponseError> {
42281- Err(mailpot_web::ResponseError::new(
42282- "Not allowed".to_string(),
42283- StatusCode::UNAUTHORIZED,
42284- ))
42285- }
42286-
42287- async fn get_list_subs(
42288- ListSubscriptionPath(id): ListSubscriptionPath,
42289- State(state): State<Arc<Configuration>>,
42290- ) -> Result<Json<Vec<ListSubscription>>, ResponseError> {
42291- let db = Connection::open_db(Configuration::clone(&state))?;
42292- let subs = match id {
42293- ListPathIdentifier::Pk(id) => db.list_subscriptions(id)?,
42294- ListPathIdentifier::Id(id) => {
42295- if let Some(v) = db.list_by_id(id)?.map(|l| db.list_subscriptions(l.pk())) {
42296- v?
42297- } else {
42298- return Err(mailpot_web::ResponseError::new(
42299- "Not found".to_string(),
42300- StatusCode::NOT_FOUND,
42301- ));
42302- }
42303- }
42304- };
42305- Ok(Json(subs.into_iter().map(DbVal::into_inner).collect()))
42306- }
42307-
42308- async fn new_list_sub(
42309- ListSubscriptionPath(_id): ListSubscriptionPath,
42310- State(_state): State<Arc<Configuration>>,
42311- //Json(_body): Json<GetRequest>,
42312- ) -> Result<Json<ListSubscription>, ResponseError> {
42313- Err(mailpot_web::ResponseError::new(
42314- "Not allowed".to_string(),
42315- StatusCode::UNAUTHORIZED,
42316- ))
42317- }
42318-
42319- #[cfg(test)]
42320- mod tests {
42321-
42322- use axum::{
42323- body::Body,
42324- http::{method::Method, Request, StatusCode},
42325- };
42326- use mailpot::{models::*, Configuration, Connection, SendMail};
42327- use mailpot_tests::init_stderr_logging;
42328- use serde_json::json;
42329- use tempfile::TempDir;
42330- use tower::ServiceExt; // for `oneshot` and `ready`
42331-
42332- use super::*;
42333-
42334- #[tokio::test]
42335- async fn test_list_router() {
42336- init_stderr_logging();
42337-
42338- let tmp_dir = TempDir::new().unwrap();
42339-
42340- let db_path = tmp_dir.path().join("mpot.db");
42341- std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
42342- let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
42343- #[allow(clippy::permissions_set_readonly_false)]
42344- perms.set_readonly(false);
42345- std::fs::set_permissions(&db_path, perms).unwrap();
42346- let config = Configuration {
42347- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
42348- db_path,
42349- data_path: tmp_dir.path().to_path_buf(),
42350- administrators: vec![],
42351- };
42352-
42353- let db = Connection::open_db(config.clone()).unwrap().trusted();
42354- assert!(!db.lists().unwrap().is_empty());
42355- let foo_chat = MailingList {
42356- pk: 1,
42357- name: "foobar chat".into(),
42358- id: "foo-chat".into(),
42359- address: "foo-chat@example.com".into(),
42360- topics: vec![],
42361- description: None,
42362- archive_url: None,
42363- };
42364- assert_eq!(&db.lists().unwrap().remove(0).into_inner(), &foo_chat);
42365- drop(db);
42366-
42367- let config = Arc::new(config);
42368-
42369- // ------------------------------------------------------------
42370- // all_lists() get total
42371-
42372- let response = crate::create_app(config.clone())
42373- .oneshot(
42374- Request::builder()
42375- .uri("/v1/list/")
42376- .body(Body::empty())
42377- .unwrap(),
42378- )
42379- .await
42380- .unwrap();
42381-
42382- assert_eq!(response.status(), StatusCode::OK);
42383-
42384- let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
42385- let r: GetResponse = serde_json::from_slice(&body).unwrap();
42386-
42387- assert_eq!(&r.entries, &[]);
42388- assert_eq!(r.total, 1);
42389- assert_eq!(r.start, 0);
42390-
42391- // ------------------------------------------------------------
42392- // all_lists() with count
42393-
42394- let response = crate::create_app(config.clone())
42395- .oneshot(
42396- Request::builder()
42397- .uri("/v1/list/?count=20")
42398- .body(Body::empty())
42399- .unwrap(),
42400- )
42401- .await
42402- .unwrap();
42403- assert_eq!(response.status(), StatusCode::OK);
42404-
42405- let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
42406- let r: GetResponse = serde_json::from_slice(&body).unwrap();
42407-
42408- assert_eq!(&r.entries, &[foo_chat.clone()]);
42409- assert_eq!(r.total, 1);
42410- assert_eq!(r.start, 0);
42411-
42412- // ------------------------------------------------------------
42413- // new_list()
42414-
42415- let response = crate::create_app(config.clone())
42416- .oneshot(
42417- Request::builder()
42418- .uri("/v1/list/")
42419- .header("Content-Type", "application/json")
42420- .method(Method::POST)
42421- .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap()))
42422- .unwrap(),
42423- )
42424- .await
42425- .unwrap();
42426- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
42427-
42428- // ------------------------------------------------------------
42429- // get_list()
42430-
42431- let response = crate::create_app(config.clone())
42432- .oneshot(
42433- Request::builder()
42434- .uri("/v1/list/1/")
42435- .header("Content-Type", "application/json")
42436- .method(Method::GET)
42437- .body(Body::empty())
42438- .unwrap(),
42439- )
42440- .await
42441- .unwrap();
42442- assert_eq!(response.status(), StatusCode::OK);
42443- let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
42444- let r: MailingList = serde_json::from_slice(&body).unwrap();
42445- assert_eq!(&r, &foo_chat);
42446-
42447- // ------------------------------------------------------------
42448- // get_list_subs()
42449-
42450- let response = crate::create_app(config.clone())
42451- .oneshot(
42452- Request::builder()
42453- .uri("/v1/list/1/subscription/")
42454- .header("Content-Type", "application/json")
42455- .method(Method::GET)
42456- .body(Body::empty())
42457- .unwrap(),
42458- )
42459- .await
42460- .unwrap();
42461- assert_eq!(response.status(), StatusCode::OK);
42462- let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
42463- let r: Vec<ListSubscription> = serde_json::from_slice(&body).unwrap();
42464- assert_eq!(
42465- &r,
42466- &[ListSubscription {
42467- pk: 1,
42468- list: 1,
42469- address: "user@example.com".to_string(),
42470- name: Some("Name".to_string()),
42471- account: Some(1),
42472- enabled: true,
42473- verified: false,
42474- digest: false,
42475- hide_address: false,
42476- receive_duplicates: true,
42477- receive_own_posts: false,
42478- receive_confirmation: true
42479- }]
42480- );
42481-
42482- // ------------------------------------------------------------
42483- // new_list_sub()
42484-
42485- let response = crate::create_app(config.clone())
42486- .oneshot(
42487- Request::builder()
42488- .uri("/v1/list/1/subscription/")
42489- .header("Content-Type", "application/json")
42490- .method(Method::POST)
42491- .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap()))
42492- .unwrap(),
42493- )
42494- .await
42495- .unwrap();
42496- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
42497-
42498- // ------------------------------------------------------------
42499- // get_list_owner()
42500-
42501- let response = crate::create_app(config.clone())
42502- .oneshot(
42503- Request::builder()
42504- .uri("/v1/list/1/owner/")
42505- .header("Content-Type", "application/json")
42506- .method(Method::GET)
42507- .body(Body::empty())
42508- .unwrap(),
42509- )
42510- .await
42511- .unwrap();
42512- assert_eq!(response.status(), StatusCode::OK);
42513- let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
42514- let r: Vec<ListOwner> = serde_json::from_slice(&body).unwrap();
42515- assert_eq!(
42516- &r,
42517- &[ListOwner {
42518- pk: 1,
42519- list: 1,
42520- address: "user@example.com".into(),
42521- name: None
42522- }]
42523- );
42524-
42525- // ------------------------------------------------------------
42526- // new_list_owner()
42527-
42528- let response = crate::create_app(config.clone())
42529- .oneshot(
42530- Request::builder()
42531- .uri("/v1/list/1/owner/")
42532- .header("Content-Type", "application/json")
42533- .method(Method::POST)
42534- .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap()))
42535- .unwrap(),
42536- )
42537- .await
42538- .unwrap();
42539- assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
42540- }
42541- }
42542 diff --git a/rest-http/src/routes/mod.rs b/rest-http/src/routes/mod.rs
42543deleted file mode 100644
42544index d17e233..0000000
42545--- a/rest-http/src/routes/mod.rs
42546+++ /dev/null
42547 @@ -1 +0,0 @@
42548- pub mod list;
42549 diff --git a/rest-http/src/settings.rs b/rest-http/src/settings.rs
42550deleted file mode 100644
42551index b1ef467..0000000
42552--- a/rest-http/src/settings.rs
42553+++ /dev/null
42554 @@ -1,61 +0,0 @@
42555- use std::{env, fmt};
42556-
42557- use config::{Config, ConfigError, Environment, File};
42558- use lazy_static::lazy_static;
42559- use serde::Deserialize;
42560-
42561- lazy_static! {
42562- pub static ref SETTINGS: Settings = Settings::new().expect("Failed to setup settings");
42563- }
42564-
42565- #[derive(Debug, Clone, Deserialize)]
42566- pub struct Server {
42567- pub port: u16,
42568- }
42569-
42570- #[derive(Debug, Clone, Deserialize)]
42571- pub struct Logger {
42572- pub level: String,
42573- }
42574-
42575- #[derive(Debug, Clone, Deserialize)]
42576- pub struct Auth {
42577- pub secret: String,
42578- }
42579-
42580- #[derive(Debug, Clone, Deserialize)]
42581- pub struct Settings {
42582- pub environment: String,
42583- pub server: Server,
42584- pub logger: Logger,
42585- pub auth: Auth,
42586- }
42587-
42588- impl Settings {
42589- pub fn new() -> Result<Self, ConfigError> {
42590- let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
42591-
42592- let mut builder = Config::builder()
42593- .add_source(File::with_name("config/default"))
42594- .add_source(File::with_name(&format!("config/{run_mode}")).required(false))
42595- .add_source(File::with_name("config/local").required(false))
42596- .add_source(Environment::default().separator("__"));
42597-
42598- // Some cloud services like Heroku exposes a randomly assigned port in
42599- // the PORT env var and there is no way to change the env var name.
42600- if let Ok(port) = env::var("PORT") {
42601- builder = builder.set_override("server.port", port)?;
42602- }
42603-
42604- builder
42605- .build()?
42606- // Deserialize (and thus freeze) the entire configuration.
42607- .try_deserialize()
42608- }
42609- }
42610-
42611- impl fmt::Display for Server {
42612- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
42613- write!(f, "http://localhost:{}", &self.port)
42614- }
42615- }
42616 diff --git a/web/.gitignore b/web/.gitignore
42617deleted file mode 100644
42618index 3523f09..0000000
42619--- a/web/.gitignore
42620+++ /dev/null
42621 @@ -1 +0,0 @@
42622- src/minijinja_utils/compressed.data
42623 diff --git a/web/Cargo.toml b/web/Cargo.toml
42624deleted file mode 100644
42625index db0ba70..0000000
42626--- a/web/Cargo.toml
42627+++ /dev/null
42628 @@ -1,59 +0,0 @@
42629- [package]
42630- name = "mailpot-web"
42631- version = "0.1.1"
42632- authors = ["Manos Pitsidianakis <el13635@mail.ntua.gr>"]
42633- edition = "2021"
42634- license = "LICENSE"
42635- readme = "README.md"
42636- description = "mailing list manager"
42637- repository = "https://github.com/meli/mailpot"
42638- keywords = ["mail", "mailing-lists"]
42639- categories = ["email"]
42640-
42641- [[bin]]
42642- name = "mpot-web"
42643- path = "src/main.rs"
42644- doc-scrape-examples = true
42645-
42646- [features]
42647- default = ["ssh-key"]
42648- ssh-key = ["dep:ssh-key"]
42649-
42650- [dependencies]
42651- axum = { version = "^0.6" }
42652- axum-extra = { version = "^0.7", features = ["typed-routing"] }
42653- axum-login = { version = "^0.5" }
42654- axum-sessions = { version = "^0.5" }
42655- build-info = { version = "0.0.31" }
42656- cfg-if = { version = "1" }
42657- chrono = { version = "^0.4" }
42658- convert_case = { version = "^0.4" }
42659- dyn-clone = { version = "^1" }
42660- eyre = { version = "0.6" }
42661- http = "0.2"
42662- indexmap = { version = "1.9" }
42663- lazy_static = "^1.4"
42664- mailpot = { version = "^0.1", path = "../core" }
42665- minijinja = { version = "0.31.0", features = ["source", ] }
42666- percent-encoding = { version = "^2.1" }
42667- rand = { version = "^0.8", features = ["min_const_gen"] }
42668- serde = { version = "^1", features = ["derive", ] }
42669- serde_json = "^1"
42670- ssh-key = { version = "0.6.2", optional = true, features = ["crypto"] }
42671- stderrlog = { version = "^0.6" }
42672- tempfile = { version = "3.9" }
42673- tokio = { version = "1", features = ["full"] }
42674- tower-http = { version = "^0.3" }
42675- tower-service = { version = "^0.3" }
42676- zstd = { version = "0.12", default-features = false }
42677-
42678- [dev-dependencies]
42679- hyper = { version = "0.14" }
42680- mailpot-tests = { version = "^0.1", path = "../mailpot-tests" }
42681- serde_urlencoded = { version = "^0.7" }
42682- tempfile = { version = "3.9" }
42683- tower = { version = "^0.4" }
42684-
42685- [build-dependencies]
42686- build-info-build = { version = "0.0.31" }
42687- zstd = { version = "0.12", default-features = false }
42688 diff --git a/web/README.md b/web/README.md
42689deleted file mode 100644
42690index c54e80c..0000000
42691--- a/web/README.md
42692+++ /dev/null
42693 @@ -1,20 +0,0 @@
42694- # mailpot web server
42695-
42696- ```shell
42697- cargo run --bin mpot-web -- /path/to/conf.toml
42698- ```
42699-
42700- Templates are compressed with `zstd` and bundled in the binary.
42701-
42702- ## Configuration
42703-
42704- By default, the server listens on `0.0.0.0:3000`.
42705- The following environment variables can be defined to configure various settings:
42706-
42707- - `HOSTNAME`, default `0.0.0.0`.
42708- - `PORT`, default `3000`.
42709- - `PUBLIC_URL`, default `lists.mailpot.rs`.
42710- - `SITE_TITLE`, default `mailing list archive`.
42711- - `SITE_SUBTITLE`, default empty.
42712- - `ROOT_URL_PREFIX`, default empty.
42713- - `SSH_NAMESPACE`, default `lists.mailpot.rs`.
42714 diff --git a/web/build.rs b/web/build.rs
42715deleted file mode 100644
42716index 5008bdc..0000000
42717--- a/web/build.rs
42718+++ /dev/null
42719 @@ -1,105 +0,0 @@
42720- /*
42721- * This file is part of mailpot
42722- *
42723- * Copyright 2020 - Manos Pitsidianakis
42724- *
42725- * This program is free software: you can redistribute it and/or modify
42726- * it under the terms of the GNU Affero General Public License as
42727- * published by the Free Software Foundation, either version 3 of the
42728- * License, or (at your option) any later version.
42729- *
42730- * This program is distributed in the hope that it will be useful,
42731- * but WITHOUT ANY WARRANTY; without even the implied warranty of
42732- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
42733- * GNU Affero General Public License for more details.
42734- *
42735- * You should have received a copy of the GNU Affero General Public License
42736- * along with this program. If not, see <https://www.gnu.org/licenses/>.
42737- */
42738-
42739- fn commit_sha() {
42740- build_info_build::build_script();
42741-
42742- if let Ok(s) = std::fs::read_to_string(".cargo_vcs_info.json") {
42743- const KEY: &str = "\"sha1\":";
42744-
42745- fn find_tail<'str>(str: &'str str, tok: &str) -> Option<&'str str> {
42746- let i = str.find(tok)?;
42747- Some(&str[(i + tok.len())..])
42748- }
42749-
42750- if let Some(mut tail) = find_tail(&s, KEY) {
42751- while !tail.starts_with('"') && !tail.is_empty() {
42752- tail = &tail[1..];
42753- }
42754- if !tail.is_empty() {
42755- // skip "
42756- tail = &tail[1..];
42757- if let Some(end) = find_tail(tail, "\"") {
42758- let end = tail.len() - end.len() - 1;
42759- println!("cargo:rustc-env=PACKAGE_GIT_SHA={}", &tail[..end]);
42760- }
42761- }
42762- }
42763- }
42764- }
42765-
42766- fn main() -> Result<(), Box<dyn std::error::Error>> {
42767- // Embed HTML templates as zstd compressed byte slices into binary.
42768- // [tag:embed_templates]
42769-
42770- use std::{
42771- fs::{create_dir_all, read_dir, OpenOptions},
42772- io::{Read, Write},
42773- path::PathBuf,
42774- };
42775- create_dir_all("./src/minijinja_utils")?;
42776- let mut compressed = OpenOptions::new()
42777- .write(true)
42778- .create(true)
42779- .truncate(true)
42780- .open("./src/minijinja_utils/compressed.data")?;
42781-
42782- println!("cargo:rerun-if-changed=./src/templates");
42783- println!("cargo:rerun-if-changed=./src/minijinja_utils/compressed.rs");
42784-
42785- let mut templates: Vec<(String, PathBuf)> = vec![];
42786- let root_prefix: PathBuf = "./src/templates/".into();
42787- let mut dirs: Vec<PathBuf> = vec!["./src/templates/".into()];
42788- while let Some(dir) = dirs.pop() {
42789- for entry in read_dir(dir)? {
42790- let entry = entry?;
42791- let path = entry.path();
42792- if path.is_dir() {
42793- dirs.push(path);
42794- } else if path.extension().map(|s| s == "html").unwrap_or(false) {
42795- templates.push((path.strip_prefix(&root_prefix)?.display().to_string(), path));
42796- }
42797- }
42798- }
42799-
42800- compressed.write_all(b"&[")?;
42801- for (name, template_path) in templates {
42802- let mut templ = OpenOptions::new()
42803- .write(false)
42804- .create(false)
42805- .read(true)
42806- .open(&template_path)?;
42807- let mut templ_bytes = vec![];
42808- let mut compressed_bytes = vec![];
42809- let mut enc = zstd::stream::write::Encoder::new(&mut compressed_bytes, 21)?;
42810- enc.include_checksum(true)?;
42811- templ.read_to_end(&mut templ_bytes)?;
42812- enc.write_all(&templ_bytes)?;
42813- enc.finish()?;
42814- compressed.write_all(b"(\"")?;
42815- compressed.write_all(name.as_bytes())?;
42816- compressed.write_all(b"\",&")?;
42817- compressed.write_all(format!("{:?}", compressed_bytes).as_bytes())?;
42818- compressed.write_all(b"),")?;
42819- }
42820- compressed.write_all(b"]")?;
42821-
42822- commit_sha();
42823- Ok(())
42824- }
42825 diff --git a/web/rustfmt.toml b/web/rustfmt.toml
42826deleted file mode 120000
42827index 39f97b0..0000000
42828--- a/web/rustfmt.toml
42829+++ /dev/null
42830 @@ -1 +0,0 @@
42831- ../rustfmt.toml
42832\ No newline at end of file
42833 diff --git a/web/src/auth.rs b/web/src/auth.rs
42834deleted file mode 100644
42835index 5da49ae..0000000
42836--- a/web/src/auth.rs
42837+++ /dev/null
42838 @@ -1,844 +0,0 @@
42839- /*
42840- * This file is part of mailpot
42841- *
42842- * Copyright 2020 - Manos Pitsidianakis
42843- *
42844- * This program is free software: you can redistribute it and/or modify
42845- * it under the terms of the GNU Affero General Public License as
42846- * published by the Free Software Foundation, either version 3 of the
42847- * License, or (at your option) any later version.
42848- *
42849- * This program is distributed in the hope that it will be useful,
42850- * but WITHOUT ANY WARRANTY; without even the implied warranty of
42851- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
42852- * GNU Affero General Public License for more details.
42853- *
42854- * You should have received a copy of the GNU Affero General Public License
42855- * along with this program. If not, see <https://www.gnu.org/licenses/>.
42856- */
42857-
42858- use std::{borrow::Cow, process::Stdio};
42859-
42860- use tempfile::NamedTempFile;
42861- use tokio::{fs::File, io::AsyncWriteExt, process::Command};
42862-
42863- use super::*;
42864-
42865- const TOKEN_KEY: &str = "ssh_challenge";
42866- const EXPIRY_IN_SECS: i64 = 6 * 60;
42867-
42868- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Eq, PartialEq, PartialOrd)]
42869- pub enum Role {
42870- User,
42871- Admin,
42872- }
42873-
42874- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
42875- pub struct User {
42876- /// SSH signature.
42877- pub ssh_signature: String,
42878- /// User role.
42879- pub role: Role,
42880- /// Database primary key.
42881- pub pk: i64,
42882- /// Accounts's display name, optional.
42883- pub name: Option<String>,
42884- /// Account's e-mail address.
42885- pub address: String,
42886- /// GPG public key.
42887- pub public_key: Option<String>,
42888- /// SSH public key.
42889- pub password: String,
42890- /// Whether this account is enabled.
42891- pub enabled: bool,
42892- }
42893-
42894- impl AuthUser<i64, Role> for User {
42895- fn get_id(&self) -> i64 {
42896- self.pk
42897- }
42898-
42899- fn get_password_hash(&self) -> SecretVec<u8> {
42900- SecretVec::new(self.ssh_signature.clone().into())
42901- }
42902-
42903- fn get_role(&self) -> Option<Role> {
42904- Some(self.role)
42905- }
42906- }
42907-
42908- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
42909- pub struct AuthFormPayload {
42910- pub address: String,
42911- pub password: String,
42912- }
42913-
42914- pub async fn ssh_signin(
42915- _: LoginPath,
42916- mut session: WritableSession,
42917- Query(next): Query<Next>,
42918- auth: AuthContext,
42919- State(state): State<Arc<AppState>>,
42920- ) -> impl IntoResponse {
42921- if auth.current_user.is_some() {
42922- if let Err(err) = session.add_message(Message {
42923- message: "You are already logged in.".into(),
42924- level: Level::Info,
42925- }) {
42926- return err.into_response();
42927- }
42928- return next
42929- .or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri()))
42930- .into_response();
42931- }
42932- if next.next.is_some() {
42933- if let Err(err) = session.add_message(Message {
42934- message: "You need to be logged in to access this page.".into(),
42935- level: Level::Info,
42936- }) {
42937- return err.into_response();
42938- };
42939- }
42940-
42941- let now: i64 = chrono::offset::Utc::now().timestamp();
42942-
42943- let prev_token = if let Some(tok) = session.get::<(String, i64)>(TOKEN_KEY) {
42944- let timestamp: i64 = tok.1;
42945- if !(timestamp < now && now - timestamp < EXPIRY_IN_SECS) {
42946- session.remove(TOKEN_KEY);
42947- None
42948- } else {
42949- Some(tok)
42950- }
42951- } else {
42952- None
42953- };
42954-
42955- let (token, timestamp): (String, i64) = prev_token.map_or_else(
42956- || {
42957- use rand::{distributions::Alphanumeric, thread_rng, Rng};
42958-
42959- let mut rng = thread_rng();
42960- let chars: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect();
42961- println!("Random chars: {}", chars);
42962- session.insert(TOKEN_KEY, (&chars, now)).unwrap();
42963- (chars, now)
42964- },
42965- |tok| tok,
42966- );
42967- let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
42968-
42969- let crumbs = vec![
42970- Crumb {
42971- label: "Home".into(),
42972- url: "/".into(),
42973- },
42974- Crumb {
42975- label: "Sign in".into(),
42976- url: LoginPath.to_crumb(),
42977- },
42978- ];
42979-
42980- let context = minijinja::context! {
42981- namespace => &state.public_url,
42982- page_title => "Log in",
42983- ssh_challenge => token,
42984- timeout_left => timeout_left,
42985- current_user => auth.current_user,
42986- messages => session.drain_messages(),
42987- crumbs => crumbs,
42988- };
42989- Html(
42990- TEMPLATES
42991- .get_template("auth.html")
42992- .unwrap()
42993- .render(context)
42994- .unwrap_or_else(|err| err.to_string()),
42995- )
42996- .into_response()
42997- }
42998-
42999- #[allow(non_snake_case)]
43000- pub async fn ssh_signin_POST(
43001- _: LoginPath,
43002- mut session: WritableSession,
43003- Query(next): Query<Next>,
43004- mut auth: AuthContext,
43005- Form(payload): Form<AuthFormPayload>,
43006- state: Arc<AppState>,
43007- ) -> Result<Redirect, ResponseError> {
43008- if auth.current_user.as_ref().is_some() {
43009- session.add_message(Message {
43010- message: "You are already logged in.".into(),
43011- level: Level::Info,
43012- })?;
43013- return Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())));
43014- }
43015-
43016- let now: i64 = chrono::offset::Utc::now().timestamp();
43017-
43018- let (_prev_token, _) = if let Some(tok @ (_, timestamp)) =
43019- session.get::<(String, i64)>(TOKEN_KEY)
43020- {
43021- if !(timestamp <= now && now - timestamp < EXPIRY_IN_SECS) {
43022- session.add_message(Message {
43023- message: "The token has expired. Please retry.".into(),
43024- level: Level::Error,
43025- })?;
43026- return Ok(Redirect::to(&format!(
43027- "{}{}?next={}",
43028- state.root_url_prefix,
43029- LoginPath.to_uri(),
43030- next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
43031- "?next={}",
43032- percent_encoding::utf8_percent_encode(
43033- next.as_str(),
43034- percent_encoding::CONTROLS
43035- )
43036- )
43037- .into())
43038- )));
43039- } else {
43040- tok
43041- }
43042- } else {
43043- session.add_message(Message {
43044- message: "The token has expired. Please retry.".into(),
43045- level: Level::Error,
43046- })?;
43047- return Ok(Redirect::to(&format!(
43048- "{}{}{}",
43049- state.root_url_prefix,
43050- LoginPath.to_uri(),
43051- next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
43052- "?next={}",
43053- percent_encoding::utf8_percent_encode(next.as_str(), percent_encoding::CONTROLS)
43054- )
43055- .into())
43056- )));
43057- };
43058-
43059- let db = Connection::open_db(state.conf.clone())?;
43060- let mut acc = match db
43061- .account_by_address(&payload.address)
43062- .with_status(StatusCode::BAD_REQUEST)?
43063- {
43064- Some(v) => v,
43065- None => {
43066- session.add_message(Message {
43067- message: "Invalid account details, please retry.".into(),
43068- level: Level::Error,
43069- })?;
43070- return Ok(Redirect::to(&format!(
43071- "{}{}{}",
43072- state.root_url_prefix,
43073- LoginPath.to_uri(),
43074- next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
43075- "?next={}",
43076- percent_encoding::utf8_percent_encode(
43077- next.as_str(),
43078- percent_encoding::CONTROLS
43079- )
43080- )
43081- .into())
43082- )));
43083- }
43084- };
43085- #[cfg(not(debug_assertions))]
43086- let sig = SshSignature {
43087- email: payload.address.clone(),
43088- ssh_public_key: acc.password.clone(),
43089- ssh_signature: payload.password.clone(),
43090- namespace: std::env::var("SSH_NAMESPACE")
43091- .unwrap_or_else(|_| "lists.mailpot.rs".to_string())
43092- .into(),
43093- token: _prev_token,
43094- };
43095- #[cfg(not(debug_assertions))]
43096- {
43097- #[cfg(not(feature = "ssh-key"))]
43098- let ssh_verify_fn = ssh_verify;
43099- #[cfg(feature = "ssh-key")]
43100- let ssh_verify_fn = ssh_verify_in_memory;
43101- if let Err(err) = ssh_verify_fn(sig).await {
43102- session.add_message(Message {
43103- message: format!("Could not verify signature: {err}").into(),
43104- level: Level::Error,
43105- })?;
43106- return Ok(Redirect::to(&format!(
43107- "{}{}{}",
43108- state.root_url_prefix,
43109- LoginPath.to_uri(),
43110- next.next.as_ref().map_or(Cow::Borrowed(""), |next| format!(
43111- "?next={}",
43112- percent_encoding::utf8_percent_encode(
43113- next.as_str(),
43114- percent_encoding::CONTROLS
43115- )
43116- )
43117- .into())
43118- )));
43119- }
43120- }
43121-
43122- let user = User {
43123- pk: acc.pk(),
43124- ssh_signature: payload.password,
43125- role: if db
43126- .conf()
43127- .administrators
43128- .iter()
43129- .any(|a| a.eq_ignore_ascii_case(&payload.address))
43130- {
43131- Role::Admin
43132- } else {
43133- Role::User
43134- },
43135- public_key: std::mem::take(&mut acc.public_key),
43136- password: std::mem::take(&mut acc.password),
43137- name: std::mem::take(&mut acc.name),
43138- address: payload.address,
43139- enabled: acc.enabled,
43140- };
43141- state.insert_user(acc.pk(), user.clone()).await;
43142- drop(session);
43143- auth.login(&user)
43144- .await
43145- .map_err(|err| ResponseError::new(err.to_string(), StatusCode::BAD_REQUEST))?;
43146- Ok(next.or_else(|| format!("{}{}", state.root_url_prefix, SettingsPath.to_uri())))
43147- }
43148-
43149- #[derive(Debug, Clone, Default)]
43150- pub struct SshSignature {
43151- pub email: String,
43152- pub ssh_public_key: String,
43153- pub ssh_signature: String,
43154- pub namespace: Cow<'static, str>,
43155- pub token: String,
43156- }
43157-
43158- /// Run ssh signature validation with `ssh-keygen` binary.
43159- ///
43160- /// ```no_run
43161- /// use mailpot_web::{ssh_verify, SshSignature};
43162- ///
43163- /// async fn verify_signature(
43164- /// ssh_public_key: String,
43165- /// ssh_signature: String,
43166- /// ) -> std::result::Result<(), Box<dyn std::error::Error>> {
43167- /// let sig = SshSignature {
43168- /// email: "user@example.com".to_string(),
43169- /// ssh_public_key,
43170- /// ssh_signature,
43171- /// namespace: "doc-test@example.com".into(),
43172- /// token: "d074a61990".to_string(),
43173- /// };
43174- ///
43175- /// ssh_verify(sig).await?;
43176- /// Ok(())
43177- /// }
43178- /// ```
43179- pub async fn ssh_verify(sig: SshSignature) -> Result<(), Box<dyn std::error::Error>> {
43180- let SshSignature {
43181- email,
43182- ssh_public_key,
43183- ssh_signature,
43184- namespace,
43185- token,
43186- } = sig;
43187- let dir = tempfile::tempdir()?;
43188-
43189- let mut allowed_signers_fp = NamedTempFile::new_in(dir.path())?;
43190- let mut signature_fp = NamedTempFile::new_in(dir.path())?;
43191- {
43192- let (tempfile, path) = allowed_signers_fp.into_parts();
43193- let mut file = File::from(tempfile);
43194-
43195- file.write_all(format!("{email} {ssh_public_key}").as_bytes())
43196- .await?;
43197- file.flush().await?;
43198- allowed_signers_fp = NamedTempFile::from_parts(file.into_std().await, path);
43199- }
43200- {
43201- let (tempfile, path) = signature_fp.into_parts();
43202- let mut file = File::from(tempfile);
43203-
43204- file.write_all(ssh_signature.trim().replace("\r\n", "\n").as_bytes())
43205- .await?;
43206- file.flush().await?;
43207- signature_fp = NamedTempFile::from_parts(file.into_std().await, path);
43208- }
43209-
43210- let mut cmd = Command::new("ssh-keygen");
43211-
43212- cmd.stdout(Stdio::piped());
43213- cmd.stderr(Stdio::piped());
43214- cmd.stdin(Stdio::piped());
43215-
43216- // Once you have your allowed signers file, verification works like this:
43217- //
43218- // ```shell
43219- // ssh-keygen -Y verify -f allowed_signers -I alice@example.com -n file -s file_to_verify.sig < file_to_verify
43220- // ```
43221- //
43222- // Here are the arguments you may need to change:
43223- //
43224- // - `allowed_signers` is the path to the allowed signers file.
43225- // - `alice@example.com` is the email address of the person who allegedly signed
43226- // the file. This email address is looked up in the allowed signers file to
43227- // get possible public keys.
43228- // - `file` is the "namespace", which must match the namespace used for signing
43229- // as described above.
43230- // - `file_to_verify.sig` is the path to the signature file.
43231- // - `file_to_verify` is the path to the file to be verified. Note that this
43232- // file is read from standard in. In the above command, the < shell operator
43233- // is used to redirect standard in from this file.
43234- //
43235- // If the signature is valid, the command exits with status `0` and prints a
43236- // message like this:
43237- //
43238- // > Good "file" signature for alice@example.com with ED25519 key
43239- // > SHA256:ZGa8RztddW4kE2XKPPsP9ZYC7JnMObs6yZzyxg8xZSk
43240- //
43241- // Otherwise, the command exits with a non-zero status and prints an error
43242- // message.
43243-
43244- let mut child = cmd
43245- .arg("-Y")
43246- .arg("verify")
43247- .arg("-f")
43248- .arg(allowed_signers_fp.path())
43249- .arg("-I")
43250- .arg(&email)
43251- .arg("-n")
43252- .arg(namespace.as_ref())
43253- .arg("-s")
43254- .arg(signature_fp.path())
43255- .spawn()
43256- .expect("failed to spawn command");
43257-
43258- let mut stdin = child
43259- .stdin
43260- .take()
43261- .expect("child did not have a handle to stdin");
43262-
43263- stdin
43264- .write_all(token.as_bytes())
43265- .await
43266- .expect("could not write to stdin");
43267-
43268- drop(stdin);
43269-
43270- let op = child.wait_with_output().await?;
43271-
43272- if !op.status.success() {
43273- return Err(format!(
43274- "ssh-keygen exited with {}:\nstdout: {}\n\nstderr: {}",
43275- op.status.code().unwrap_or(-1),
43276- String::from_utf8_lossy(&op.stdout),
43277- String::from_utf8_lossy(&op.stderr)
43278- )
43279- .into());
43280- }
43281-
43282- Ok(())
43283- }
43284-
43285- /// Run ssh signature validation.
43286- ///
43287- /// ```no_run
43288- /// use mailpot_web::{ssh_verify_in_memory, SshSignature};
43289- ///
43290- /// async fn ssh_verify(
43291- /// ssh_public_key: String,
43292- /// ssh_signature: String,
43293- /// ) -> std::result::Result<(), Box<dyn std::error::Error>> {
43294- /// let sig = SshSignature {
43295- /// email: "user@example.com".to_string(),
43296- /// ssh_public_key,
43297- /// ssh_signature,
43298- /// namespace: "doc-test@example.com".into(),
43299- /// token: "d074a61990".to_string(),
43300- /// };
43301- ///
43302- /// ssh_verify_in_memory(sig).await?;
43303- /// Ok(())
43304- /// }
43305- /// ```
43306- #[cfg(feature = "ssh-key")]
43307- pub async fn ssh_verify_in_memory(sig: SshSignature) -> Result<(), Box<dyn std::error::Error>> {
43308- use ssh_key::{PublicKey, SshSig};
43309-
43310- let SshSignature {
43311- email: _,
43312- ref ssh_public_key,
43313- ref ssh_signature,
43314- ref namespace,
43315- ref token,
43316- } = sig;
43317-
43318- let public_key = ssh_public_key.parse::<PublicKey>().map_err(|err| {
43319- format!("Could not parse user's SSH public key. Is it valid? Reason given: {err}")
43320- })?;
43321- let signature = if ssh_signature.contains("\r\n") {
43322- ssh_signature.trim().replace("\r\n", "\n").parse::<SshSig>()
43323- } else {
43324- ssh_signature.parse::<SshSig>()
43325- }
43326- .map_err(|err| format!("Invalid SSH signature. Reason given: {err}"))?;
43327-
43328- if let Err(err) = public_key.verify(namespace, token.as_bytes(), &signature) {
43329- use ssh_key::Error;
43330-
43331- #[allow(clippy::wildcard_in_or_patterns)]
43332- return match err {
43333- Error::Io(err_kind) => {
43334- log::error!(
43335- "ssh signature could not be verified because of internal error:\nSignature \
43336- was {sig:#?}\nError was {err_kind}."
43337- );
43338- Err("SSH signature could not be verified because of internal error.".into())
43339- }
43340- Error::Crypto => Err("SSH signature is invalid.".into()),
43341- Error::AlgorithmUnknown
43342- | Error::AlgorithmUnsupported { .. }
43343- | Error::CertificateFieldInvalid(_)
43344- | Error::CertificateValidation
43345- | Error::Decrypted
43346- | Error::Ecdsa(_)
43347- | Error::Encoding(_)
43348- | Error::Encrypted
43349- | Error::FormatEncoding
43350- | Error::Namespace
43351- | Error::PublicKey
43352- | Error::Time
43353- | Error::TrailingData { .. }
43354- | Error::Version { .. }
43355- | _ => Err(format!("SSH signature could not be verified: Reason given: {err}").into()),
43356- };
43357- }
43358-
43359- Ok(())
43360- }
43361-
43362- pub async fn logout_handler(
43363- _: LogoutPath,
43364- mut auth: AuthContext,
43365- State(state): State<Arc<AppState>>,
43366- ) -> Redirect {
43367- auth.logout().await;
43368- Redirect::to(&format!("{}/", state.root_url_prefix))
43369- }
43370-
43371- pub mod auth_request {
43372- use std::{marker::PhantomData, ops::RangeBounds};
43373-
43374- use axum::body::HttpBody;
43375- use dyn_clone::DynClone;
43376- use tower_http::auth::AuthorizeRequest;
43377-
43378- use super::*;
43379-
43380- trait RoleBounds<Role>: DynClone + Send + Sync {
43381- fn contains(&self, role: Option<Role>) -> bool;
43382- }
43383-
43384- impl<T, Role> RoleBounds<Role> for T
43385- where
43386- Role: PartialOrd + PartialEq,
43387- T: RangeBounds<Role> + Clone + Send + Sync,
43388- {
43389- fn contains(&self, role: Option<Role>) -> bool {
43390- role.as_ref()
43391- .map_or_else(|| role.is_none(), |role| RangeBounds::contains(self, role))
43392- }
43393- }
43394-
43395- /// Type that performs login authorization.
43396- ///
43397- /// See [`RequireAuthorizationLayer::login`] for more details.
43398- pub struct Login<UserId, User, ResBody, Role = ()> {
43399- login_url: Option<Arc<Cow<'static, str>>>,
43400- redirect_field_name: Option<Arc<Cow<'static, str>>>,
43401- role_bounds: Box<dyn RoleBounds<Role>>,
43402- _user_id_type: PhantomData<UserId>,
43403- _user_type: PhantomData<User>,
43404- _body_type: PhantomData<fn() -> ResBody>,
43405- }
43406-
43407- impl<UserId, User, ResBody, Role> Clone for Login<UserId, User, ResBody, Role> {
43408- fn clone(&self) -> Self {
43409- Self {
43410- login_url: self.login_url.clone(),
43411- redirect_field_name: self.redirect_field_name.clone(),
43412- role_bounds: dyn_clone::clone_box(&*self.role_bounds),
43413- _user_id_type: PhantomData,
43414- _user_type: PhantomData,
43415- _body_type: PhantomData,
43416- }
43417- }
43418- }
43419-
43420- impl<UserId, User, ReqBody, ResBody, Role> AuthorizeRequest<ReqBody>
43421- for Login<UserId, User, ResBody, Role>
43422- where
43423- Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
43424- User: AuthUser<UserId, Role>,
43425- ResBody: HttpBody + Default,
43426- {
43427- type ResponseBody = ResBody;
43428-
43429- fn authorize(
43430- &mut self,
43431- request: &mut Request<ReqBody>,
43432- ) -> Result<(), Response<Self::ResponseBody>> {
43433- let user = request
43434- .extensions()
43435- .get::<Option<User>>()
43436- .expect("Auth extension missing. Is the auth layer installed?");
43437-
43438- match user {
43439- Some(user) if self.role_bounds.contains(user.get_role()) => {
43440- let user = user.clone();
43441- request.extensions_mut().insert(user);
43442-
43443- Ok(())
43444- }
43445-
43446- _ => {
43447- let unauthorized_response = if let Some(ref login_url) = self.login_url {
43448- let url: Cow<'static, str> = self.redirect_field_name.as_ref().map_or_else(
43449- || login_url.as_ref().clone(),
43450- |next| {
43451- format!(
43452- "{login_url}?{next}={}",
43453- percent_encoding::utf8_percent_encode(
43454- request.uri().path(),
43455- percent_encoding::CONTROLS
43456- )
43457- )
43458- .into()
43459- },
43460- );
43461-
43462- Response::builder()
43463- .status(http::StatusCode::TEMPORARY_REDIRECT)
43464- .header(http::header::LOCATION, url.as_ref())
43465- .body(Default::default())
43466- .unwrap()
43467- } else {
43468- Response::builder()
43469- .status(http::StatusCode::UNAUTHORIZED)
43470- .body(Default::default())
43471- .unwrap()
43472- };
43473-
43474- Err(unauthorized_response)
43475- }
43476- }
43477- }
43478- }
43479-
43480- /// A wrapper around [`tower_http::auth::RequireAuthorizationLayer`] which
43481- /// provides login authorization.
43482- pub struct RequireAuthorizationLayer<UserId, User, Role = ()>(UserId, User, Role);
43483-
43484- impl<UserId, User, Role> RequireAuthorizationLayer<UserId, User, Role>
43485- where
43486- Role: PartialOrd + PartialEq + Clone + Send + Sync + 'static,
43487- User: AuthUser<UserId, Role>,
43488- {
43489- /// Authorizes requests by requiring a logged in user, otherwise it
43490- /// rejects with [`http::StatusCode::UNAUTHORIZED`].
43491- pub fn login<ResBody>(
43492- ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
43493- where
43494- ResBody: HttpBody + Default,
43495- {
43496- tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
43497- login_url: None,
43498- redirect_field_name: None,
43499- role_bounds: Box::new(..),
43500- _user_id_type: PhantomData,
43501- _user_type: PhantomData,
43502- _body_type: PhantomData,
43503- })
43504- }
43505-
43506- /// Authorizes requests by requiring a logged in user to have a specific
43507- /// range of roles, otherwise it rejects with
43508- /// [`http::StatusCode::UNAUTHORIZED`].
43509- pub fn login_with_role<ResBody>(
43510- role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
43511- ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
43512- where
43513- ResBody: HttpBody + Default,
43514- {
43515- tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
43516- login_url: None,
43517- redirect_field_name: None,
43518- role_bounds: Box::new(role_bounds),
43519- _user_id_type: PhantomData,
43520- _user_type: PhantomData,
43521- _body_type: PhantomData,
43522- })
43523- }
43524-
43525- /// Authorizes requests by requiring a logged in user, otherwise it
43526- /// redirects to the provided login URL.
43527- ///
43528- /// If `redirect_field_name` is set to a value, the login page will
43529- /// receive the path it was redirected from in the URI query
43530- /// part. For example, attempting to visit a protected path
43531- /// `/protected` would redirect you to `/login?next=/protected` allowing
43532- /// you to know how to return the visitor to their requested
43533- /// page.
43534- pub fn login_or_redirect<ResBody>(
43535- login_url: Arc<Cow<'static, str>>,
43536- redirect_field_name: Option<Arc<Cow<'static, str>>>,
43537- ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
43538- where
43539- ResBody: HttpBody + Default,
43540- {
43541- tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
43542- login_url: Some(login_url),
43543- redirect_field_name,
43544- role_bounds: Box::new(..),
43545- _user_id_type: PhantomData,
43546- _user_type: PhantomData,
43547- _body_type: PhantomData,
43548- })
43549- }
43550-
43551- /// Authorizes requests by requiring a logged in user to have a specific
43552- /// range of roles, otherwise it redirects to the
43553- /// provided login URL.
43554- ///
43555- /// If `redirect_field_name` is set to a value, the login page will
43556- /// receive the path it was redirected from in the URI query
43557- /// part. For example, attempting to visit a protected path
43558- /// `/protected` would redirect you to `/login?next=/protected` allowing
43559- /// you to know how to return the visitor to their requested
43560- /// page.
43561- pub fn login_with_role_or_redirect<ResBody>(
43562- role_bounds: impl RangeBounds<Role> + Clone + Send + Sync + 'static,
43563- login_url: Arc<Cow<'static, str>>,
43564- redirect_field_name: Option<Arc<Cow<'static, str>>>,
43565- ) -> tower_http::auth::RequireAuthorizationLayer<Login<UserId, User, ResBody, Role>>
43566- where
43567- ResBody: HttpBody + Default,
43568- {
43569- tower_http::auth::RequireAuthorizationLayer::custom(Login::<_, _, _, _> {
43570- login_url: Some(login_url),
43571- redirect_field_name,
43572- role_bounds: Box::new(role_bounds),
43573- _user_id_type: PhantomData,
43574- _user_type: PhantomData,
43575- _body_type: PhantomData,
43576- })
43577- }
43578- }
43579- }
43580-
43581- #[cfg(test)]
43582- mod tests {
43583- use super::*;
43584- const PKEY: &str = concat!(
43585- "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCzXp8nLJL8GPNw7S+Dqt0m3Dw/",
43586- "xFOAdwKXcekTFI9cLDEUII2rNPf0uUZTpv57OgU+",
43587- "QOEEIvWMjz+5KSWBX8qdP8OtV0QNvynlZkEKZN0cUqGKaNXo5a+PUDyiJ2rHroPe1aMo6mUBL9kLR6J2U1CYD/dLfL8ywXsAGmOL0bsK0GRPVBJAjpUNRjpGU/",
43588- "2FFIlU6s6GawdbDXEHDox/UoOVAKIlhKabaTrFBA0ACFLRX2/GCBmHqqt5d4ZZjefYzReLs/beOjafYImoyhHC428wZDcUjvLrpSJbIOE/",
43589- "gSPCWlRbcsxg4JGcKOtALUurE+ok+avy9M7eFjGhLGSlTKLdshIVQr/3W667M7bYfOT6xP/",
43590- "lyjxeWIUYyj7rjlqKJ9tzygek7QNxCtuqH5xsZAZqzQCN8wfrPAlwDykvWityKOw+Bt2DWjimITqyKgsBsOaA+",
43591- "eVCllFvooJxoYvAjODASjAUoOdgVzyBDpFnOhLFYiIIyL3F6NROS9i7z086paX7mrzcQzvLr4ckF9qT7DrI88ikISCR9bFR4vPq3aH",
43592- "zJdjDDpWxACa5b11NG8KdCJPe/L0kDw82Q00U13CpW9FI9sZjvk+",
43593- "lyw8bTFvVsIl6A0ueboFvrNvznAqHrtfWu75fXRh5sKj2TGk8rhm3vyNgrBSr5zAfFVM8LgqBxbAAYw=="
43594- );
43595-
43596- const ARMOR_SIG: &str = concat!(
43597- "-----BEGIN SSH SIGNATURE-----\n",
43598- "U1NIU0lHAAAAAQAAAhcAAAAHc3NoLXJzYQAAAAMBAAEAAAIBALNenycskvwY83DtL4Oq3S\n",
43599- "bcPD/EU4B3Apdx6RMUj1wsMRQgjas09/S5RlOm/ns6BT5A4QQi9YyPP7kpJYFfyp0/w61X\n",
43600- "RA2/KeVmQQpk3RxSoYpo1ejlr49QPKInaseug97VoyjqZQEv2QtHonZTUJgP90t8vzLBew\n",
43601- "AaY4vRuwrQZE9UEkCOlQ1GOkZT/YUUiVTqzoZrB1sNcQcOjH9Sg5UAoiWEpptpOsUEDQAI\n",
43602- "UtFfb8YIGYeqq3l3hlmN59jNF4uz9t46Np9giajKEcLjbzBkNxSO8uulIlsg4T+BI8JaVF\n",
43603- "tyzGDgkZwo60AtS6sT6iT5q/L0zt4WMaEsZKVMot2yEhVCv/dbrrsztth85PrE/+XKPF5Y\n",
43604- "hRjKPuuOWoon23PKB6TtA3EK26ofnGxkBmrNAI3zB+s8CXAPKS9aK3Io7D4G3YNaOKYhOr\n",
43605- "IqCwGw5oD55UKWUW+ignGhi8CM4MBKMBSg52BXPIEOkWc6EsViIgjIvcXo1E5L2LvPTzql\n",
43606- "pfuavNxDO8uvhyQX2pPsOsjzyKQhIJH1sVHi8+rdofMl2MMOlbEAJrlvXU0bwp0Ik978vS\n",
43607- "QPDzZDTRTXcKlb0Uj2xmO+T6XLDxtMW9WwiXoDS55ugW+s2/OcCoeu19a7vl9dGHmwqPZM\n",
43608- "aTyuGbe/I2CsFKvnMB8VUzwuCoHFsABjAAAAFGRvYy10ZXN0QGV4YW1wbGUuY29tAAAAAA\n",
43609- "AAAAZzaGE1MTIAAAIUAAAADHJzYS1zaGEyLTUxMgAAAgBxaMqIfeapKTrhQzggDssD+76s\n",
43610- "jZxv3XxzgsuAjlIdtw+/nyxU6skTnrGoam2shpmQvx0HuqSQ7HyS2USBK7T4LZNoE53zR/\n",
43611- "ZmHLGoyQAoexiHSEW9Lk53kyRNPhpXQedTvm8REHPGM3zw6WO6mAXVVxvebvawf81LTbBb\n",
43612- "p9ubNRcHgktVeywMO/sD6zWSyShq1gjVv1PdRBOjUgqkwjImL8dFKi1QUeoffCxyk3JhTO\n",
43613- "siTy79HZSz/kOvkvL1vQuqaP2R8lE9P1uaD19dGOMTPRod3u+QmpYX47ri5KM3Fmkfxdwq\n",
43614- "p8JVmfAA9nme7bmNS1hWgmF2Nbh9qjh1zOZvCimIpuNtz5eEl9K+1DxG6w5tX86wSGvBMO\n",
43615- "znx0k1gGfkiAULqgrkdul7mqMPRvPN9J6QlNJ7SLFChRhzlJIJc6tOvCs7qkVD43Zcb+I5\n",
43616- "Z+K4NiFf5jf8kVX/pjjeW/ucbrctJIkGsZ58OkHKi1EDRcq7NtCF6SKlcv8g3fMLd9wW6K\n",
43617- "aaed0TBDC+s+f6naNIGvWqfWCwDuK5xGyDTTmJGcrsMwWuT9K6uLk8cGdv7t5mOFuWi5jl\n",
43618- "E+IKZKVABMuWqSj96ErMIiBjtsAZfNSezpsK49wQztoSPhdwLhD6fHrSAyPCqN2xRkcsIb\n",
43619- "6PxWKC/OELf3gyEBRPouxsF7xSZQ==\n",
43620- "-----END SSH SIGNATURE-----\n"
43621- );
43622-
43623- fn create_sig() -> SshSignature {
43624- SshSignature {
43625- email: "user@example.com".to_string(),
43626- ssh_public_key: PKEY.to_string(),
43627- ssh_signature: ARMOR_SIG.to_string(),
43628- namespace: "doc-test@example.com".into(),
43629- token: "d074a61990".to_string(),
43630- }
43631- }
43632-
43633- #[tokio::test]
43634- async fn test_ssh_verify() {
43635- let mut sig = create_sig();
43636- ssh_verify(sig.clone()).await.unwrap();
43637-
43638- sig.ssh_signature = sig.ssh_signature.replace('J', "0");
43639-
43640- let err = ssh_verify(sig).await.unwrap_err();
43641-
43642- assert!(
43643- err.to_string().starts_with("ssh-keygen exited with"),
43644- "{}",
43645- err
43646- );
43647- }
43648-
43649- #[cfg(feature = "ssh-key")]
43650- #[tokio::test]
43651- async fn test_ssh_verify_in_memory() {
43652- let mut sig = create_sig();
43653- ssh_verify_in_memory(sig.clone()).await.unwrap();
43654-
43655- sig.ssh_signature = sig.ssh_signature.replace('J', "0");
43656-
43657- let err = ssh_verify_in_memory(sig.clone()).await.unwrap_err();
43658-
43659- assert_eq!(
43660- &err.to_string(),
43661- "Invalid SSH signature. Reason given: invalid label: 'ssh-}3a'",
43662- "{}",
43663- err
43664- );
43665-
43666- sig.ssh_public_key = sig.ssh_public_key.replace(' ', "0");
43667-
43668- let err = ssh_verify_in_memory(sig).await.unwrap_err();
43669- assert_eq!(
43670- &err.to_string(),
43671- "Could not parse user's SSH public key. Is it valid? Reason given: length invalid",
43672- "{}",
43673- err
43674- );
43675-
43676- let mut sig = create_sig();
43677- sig.token = sig.token.replace('d', "0");
43678-
43679- let err = ssh_verify_in_memory(sig).await.unwrap_err();
43680- assert_eq!(&err.to_string(), "SSH signature is invalid.", "{}", err);
43681- }
43682- }
43683 diff --git a/web/src/cal.rs b/web/src/cal.rs
43684deleted file mode 100644
43685index 370ebc1..0000000
43686--- a/web/src/cal.rs
43687+++ /dev/null
43688 @@ -1,243 +0,0 @@
43689- // MIT License
43690- //
43691- // Copyright (c) 2021 sadnessOjisan
43692- //
43693- // Permission is hereby granted, free of charge, to any person obtaining a copy
43694- // of this software and associated documentation files (the "Software"), to deal
43695- // in the Software without restriction, including without limitation the rights
43696- // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
43697- // copies of the Software, and to permit persons to whom the Software is
43698- // furnished to do so, subject to the following conditions:
43699- //
43700- // The above copyright notice and this permission notice shall be included in
43701- // all copies or substantial portions of the Software.
43702- //
43703- // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
43704- // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
43705- // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
43706- // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
43707- // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
43708- // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43709- // SOFTWARE.
43710-
43711- use chrono::*;
43712-
43713- #[allow(dead_code)]
43714- /// Generate a calendar view of the given date's month.
43715- ///
43716- /// Each vector element is an array of seven numbers representing weeks
43717- /// (starting on Sundays), and each value is the numeric date.
43718- /// A value of zero means a date that not exists in the current month.
43719- ///
43720- /// # Examples
43721- /// ```
43722- /// use chrono::*;
43723- /// use mailpot_web::calendarize;
43724- ///
43725- /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
43726- /// // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
43727- /// println!("{:?}", calendarize(date));
43728- /// // [0, 0, 0, 0, 0, 1, 2],
43729- /// // [3, 4, 5, 6, 7, 8, 9],
43730- /// // [10, 11, 12, 13, 14, 15, 16],
43731- /// // [17, 18, 19, 20, 21, 22, 23],
43732- /// // [24, 25, 26, 27, 28, 29, 30],
43733- /// // [31, 0, 0, 0, 0, 0, 0]
43734- /// ```
43735- pub fn calendarize(date: NaiveDate) -> Vec<[u32; 7]> {
43736- calendarize_with_offset(date, 0)
43737- }
43738-
43739- /// Generate a calendar view of the given date's month and offset.
43740- ///
43741- /// Each vector element is an array of seven numbers representing weeks
43742- /// (starting on Sundays), and each value is the numeric date.
43743- /// A value of zero means a date that not exists in the current month.
43744- ///
43745- /// Offset means the number of days from sunday.
43746- /// For example, 1 means monday, 6 means saturday.
43747- ///
43748- /// # Examples
43749- /// ```
43750- /// use chrono::*;
43751- /// use mailpot_web::calendarize_with_offset;
43752- ///
43753- /// let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
43754- /// // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
43755- /// println!("{:?}", calendarize_with_offset(date, 1));
43756- /// // [0, 0, 0, 0, 1, 2, 3],
43757- /// // [4, 5, 6, 7, 8, 9, 10],
43758- /// // [11, 12, 13, 14, 15, 16, 17],
43759- /// // [18, 19, 20, 21, 22, 23, 24],
43760- /// // [25, 26, 27, 28, 29, 30, 0],
43761- /// ```
43762- pub fn calendarize_with_offset(date: NaiveDate, offset: u32) -> Vec<[u32; 7]> {
43763- let mut monthly_calendar: Vec<[u32; 7]> = Vec::with_capacity(6);
43764- let year = date.year();
43765- let month = date.month();
43766- let num_days_from_sunday = NaiveDate::from_ymd_opt(year, month, 1)
43767- .unwrap()
43768- .weekday()
43769- .num_days_from_sunday();
43770- let mut first_date_day = if num_days_from_sunday < offset {
43771- num_days_from_sunday + (7 - offset)
43772- } else {
43773- num_days_from_sunday - offset
43774- };
43775- let end_date = NaiveDate::from_ymd_opt(year, month + 1, 1)
43776- .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap())
43777- .pred_opt()
43778- .unwrap()
43779- .day();
43780-
43781- let mut date: u32 = 0;
43782- while date < end_date {
43783- let mut week: [u32; 7] = [0; 7];
43784- for day in first_date_day..7 {
43785- date += 1;
43786- week[day as usize] = date;
43787- if date >= end_date {
43788- break;
43789- }
43790- }
43791- first_date_day = 0;
43792-
43793- monthly_calendar.push(week);
43794- }
43795-
43796- monthly_calendar
43797- }
43798-
43799- #[test]
43800- fn january() {
43801- let date = NaiveDate::parse_from_str("2021-01-02", "%Y-%m-%d").unwrap();
43802- let actual = calendarize(date);
43803- assert_eq!(
43804- vec![
43805- [0, 0, 0, 0, 0, 1, 2],
43806- [3, 4, 5, 6, 7, 8, 9],
43807- [10, 11, 12, 13, 14, 15, 16],
43808- [17, 18, 19, 20, 21, 22, 23],
43809- [24, 25, 26, 27, 28, 29, 30],
43810- [31, 0, 0, 0, 0, 0, 0]
43811- ],
43812- actual
43813- );
43814- }
43815-
43816- #[test]
43817- // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
43818- fn with_offset_from_sunday() {
43819- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
43820- let actual = calendarize_with_offset(date, 0);
43821- assert_eq!(
43822- vec![
43823- [0, 0, 0, 0, 0, 1, 2],
43824- [3, 4, 5, 6, 7, 8, 9],
43825- [10, 11, 12, 13, 14, 15, 16],
43826- [17, 18, 19, 20, 21, 22, 23],
43827- [24, 25, 26, 27, 28, 29, 30],
43828- ],
43829- actual
43830- );
43831- }
43832-
43833- #[test]
43834- // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
43835- fn with_offset_from_monday() {
43836- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
43837- let actual = calendarize_with_offset(date, 1);
43838- assert_eq!(
43839- vec![
43840- [0, 0, 0, 0, 1, 2, 3],
43841- [4, 5, 6, 7, 8, 9, 10],
43842- [11, 12, 13, 14, 15, 16, 17],
43843- [18, 19, 20, 21, 22, 23, 24],
43844- [25, 26, 27, 28, 29, 30, 0],
43845- ],
43846- actual
43847- );
43848- }
43849-
43850- #[test]
43851- // Week = [Sat, Sun, Mon, Tue, Wed, Thu, Fri]
43852- fn with_offset_from_saturday() {
43853- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
43854- let actual = calendarize_with_offset(date, 6);
43855- assert_eq!(
43856- vec![
43857- [0, 0, 0, 0, 0, 0, 1],
43858- [2, 3, 4, 5, 6, 7, 8],
43859- [9, 10, 11, 12, 13, 14, 15],
43860- [16, 17, 18, 19, 20, 21, 22],
43861- [23, 24, 25, 26, 27, 28, 29],
43862- [30, 0, 0, 0, 0, 0, 0]
43863- ],
43864- actual
43865- );
43866- }
43867-
43868- #[test]
43869- // Week = [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
43870- fn with_offset_from_sunday_with7() {
43871- let date = NaiveDate::parse_from_str("2019-11-01", "%Y-%m-%d").unwrap();
43872- let actual = calendarize_with_offset(date, 7);
43873- assert_eq!(
43874- vec![
43875- [0, 0, 0, 0, 0, 1, 2],
43876- [3, 4, 5, 6, 7, 8, 9],
43877- [10, 11, 12, 13, 14, 15, 16],
43878- [17, 18, 19, 20, 21, 22, 23],
43879- [24, 25, 26, 27, 28, 29, 30],
43880- ],
43881- actual
43882- );
43883- }
43884-
43885- #[test]
43886- fn april() {
43887- let date = NaiveDate::parse_from_str("2021-04-02", "%Y-%m-%d").unwrap();
43888- let actual = calendarize(date);
43889- assert_eq!(
43890- vec![
43891- [0, 0, 0, 0, 1, 2, 3],
43892- [4, 5, 6, 7, 8, 9, 10],
43893- [11, 12, 13, 14, 15, 16, 17],
43894- [18, 19, 20, 21, 22, 23, 24],
43895- [25, 26, 27, 28, 29, 30, 0]
43896- ],
43897- actual
43898- );
43899- }
43900-
43901- #[test]
43902- fn uruudoshi() {
43903- let date = NaiveDate::parse_from_str("2020-02-02", "%Y-%m-%d").unwrap();
43904- let actual = calendarize(date);
43905- assert_eq!(
43906- vec![
43907- [0, 0, 0, 0, 0, 0, 1],
43908- [2, 3, 4, 5, 6, 7, 8],
43909- [9, 10, 11, 12, 13, 14, 15],
43910- [16, 17, 18, 19, 20, 21, 22],
43911- [23, 24, 25, 26, 27, 28, 29]
43912- ],
43913- actual
43914- );
43915- }
43916-
43917- #[test]
43918- fn uruwanaidoshi() {
43919- let date = NaiveDate::parse_from_str("2021-02-02", "%Y-%m-%d").unwrap();
43920- let actual = calendarize(date);
43921- assert_eq!(
43922- vec![
43923- [0, 1, 2, 3, 4, 5, 6],
43924- [7, 8, 9, 10, 11, 12, 13],
43925- [14, 15, 16, 17, 18, 19, 20],
43926- [21, 22, 23, 24, 25, 26, 27],
43927- [28, 0, 0, 0, 0, 0, 0]
43928- ],
43929- actual
43930- );
43931- }
43932 diff --git a/web/src/help.rs b/web/src/help.rs
43933deleted file mode 100644
43934index 9a3c9c4..0000000
43935--- a/web/src/help.rs
43936+++ /dev/null
43937 @@ -1,45 +0,0 @@
43938- /*
43939- * This file is part of mailpot
43940- *
43941- * Copyright 2020 - Manos Pitsidianakis
43942- *
43943- * This program is free software: you can redistribute it and/or modify
43944- * it under the terms of the GNU Affero General Public License as
43945- * published by the Free Software Foundation, either version 3 of the
43946- * License, or (at your option) any later version.
43947- *
43948- * This program is distributed in the hope that it will be useful,
43949- * but WITHOUT ANY WARRANTY; without even the implied warranty of
43950- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
43951- * GNU Affero General Public License for more details.
43952- *
43953- * You should have received a copy of the GNU Affero General Public License
43954- * along with this program. If not, see <https://www.gnu.org/licenses/>.
43955- */
43956-
43957- use super::*;
43958-
43959- /// Show help page.
43960- pub async fn help(
43961- _: HelpPath,
43962- mut session: WritableSession,
43963- auth: AuthContext,
43964- ) -> Result<Html<String>, ResponseError> {
43965- let crumbs = vec![
43966- Crumb {
43967- label: "Home".into(),
43968- url: "/".into(),
43969- },
43970- Crumb {
43971- label: "Help".into(),
43972- url: HelpPath.to_crumb(),
43973- },
43974- ];
43975- let context = minijinja::context! {
43976- page_title => "Help & Documentation",
43977- current_user => auth.current_user,
43978- messages => session.drain_messages(),
43979- crumbs => crumbs,
43980- };
43981- Ok(Html(TEMPLATES.get_template("help.html")?.render(context)?))
43982- }
43983 diff --git a/web/src/lib.rs b/web/src/lib.rs
43984deleted file mode 100644
43985index a7c35bd..0000000
43986--- a/web/src/lib.rs
43987+++ /dev/null
43988 @@ -1,233 +0,0 @@
43989- /*
43990- * This file is part of mailpot
43991- *
43992- * Copyright 2020 - Manos Pitsidianakis
43993- *
43994- * This program is free software: you can redistribute it and/or modify
43995- * it under the terms of the GNU Affero General Public License as
43996- * published by the Free Software Foundation, either version 3 of the
43997- * License, or (at your option) any later version.
43998- *
43999- * This program is distributed in the hope that it will be useful,
44000- * but WITHOUT ANY WARRANTY; without even the implied warranty of
44001- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
44002- * GNU Affero General Public License for more details.
44003- *
44004- * You should have received a copy of the GNU Affero General Public License
44005- * along with this program. If not, see <https://www.gnu.org/licenses/>.
44006- */
44007-
44008- #![deny(
44009- //missing_docs,
44010- rustdoc::broken_intra_doc_links,
44011- /* groups */
44012- clippy::correctness,
44013- clippy::suspicious,
44014- clippy::complexity,
44015- clippy::perf,
44016- clippy::style,
44017- clippy::cargo,
44018- clippy::nursery,
44019- /* restriction */
44020- clippy::dbg_macro,
44021- clippy::rc_buffer,
44022- clippy::as_underscore,
44023- clippy::assertions_on_result_states,
44024- /* pedantic */
44025- clippy::cast_lossless,
44026- clippy::cast_possible_wrap,
44027- clippy::ptr_as_ptr,
44028- clippy::bool_to_int_with_if,
44029- clippy::borrow_as_ptr,
44030- clippy::case_sensitive_file_extension_comparisons,
44031- clippy::cast_lossless,
44032- clippy::cast_ptr_alignment,
44033- clippy::naive_bytecount
44034- )]
44035- #![allow(clippy::multiple_crate_versions, clippy::missing_const_for_fn)]
44036-
44037- pub use axum::{
44038- extract::{Path, Query, State},
44039- handler::Handler,
44040- response::{Html, IntoResponse, Redirect},
44041- routing::{get, post},
44042- Extension, Form, Router,
44043- };
44044- pub use axum_extra::routing::TypedPath;
44045- pub use axum_login::{
44046- memory_store::MemoryStore as AuthMemoryStore, secrecy::SecretVec, AuthLayer, AuthUser,
44047- RequireAuthorizationLayer,
44048- };
44049- pub use axum_sessions::{
44050- async_session::MemoryStore,
44051- extractors::{ReadableSession, WritableSession},
44052- SessionLayer,
44053- };
44054-
44055- pub type AuthContext =
44056- axum_login::extractors::AuthContext<i64, auth::User, Arc<AppState>, auth::Role>;
44057-
44058- pub type RequireAuth = auth::auth_request::RequireAuthorizationLayer<i64, auth::User, auth::Role>;
44059-
44060- pub use std::result::Result;
44061- use std::{borrow::Cow, collections::HashMap, sync::Arc};
44062-
44063- use chrono::Datelike;
44064- pub use http::{Request, Response, StatusCode};
44065- use mailpot::{models::DbVal, rusqlite::OptionalExtension, *};
44066- use minijinja::{
44067- value::{Object, Value},
44068- Environment, Error,
44069- };
44070- use tokio::sync::RwLock;
44071-
44072- pub mod auth;
44073- pub mod cal;
44074- pub mod help;
44075- pub mod lists;
44076- pub mod minijinja_utils;
44077- pub mod settings;
44078- pub mod topics;
44079- pub mod typed_paths;
44080- pub mod utils;
44081-
44082- pub use auth::*;
44083- pub use cal::{calendarize, *};
44084- pub use help::*;
44085- pub use lists::{
44086- list, list_candidates, list_edit, list_edit_POST, list_post, list_post_eml, list_post_raw,
44087- list_subscribers, PostPolicySettings, SubscriptionPolicySettings,
44088- };
44089- pub use minijinja_utils::*;
44090- pub use settings::{
44091- settings, settings_POST, user_list_subscription, user_list_subscription_POST,
44092- SubscriptionFormPayload,
44093- };
44094- pub use topics::*;
44095- pub use typed_paths::{tsr::RouterExt, *};
44096- pub use utils::*;
44097-
44098- #[derive(Debug)]
44099- pub struct ResponseError {
44100- pub inner: Box<dyn std::error::Error>,
44101- pub status: StatusCode,
44102- }
44103-
44104- impl std::fmt::Display for ResponseError {
44105- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
44106- write!(fmt, "Inner: {}, status: {}", self.inner, self.status)
44107- }
44108- }
44109-
44110- impl ResponseError {
44111- pub fn new(msg: String, status: StatusCode) -> Self {
44112- Self {
44113- inner: Box::<dyn std::error::Error + Send + Sync>::from(msg),
44114- status,
44115- }
44116- }
44117- }
44118-
44119- impl<E: Into<Box<dyn std::error::Error>>> From<E> for ResponseError {
44120- fn from(err: E) -> Self {
44121- Self {
44122- inner: err.into(),
44123- status: StatusCode::INTERNAL_SERVER_ERROR,
44124- }
44125- }
44126- }
44127-
44128- pub trait IntoResponseError {
44129- fn with_status(self, status: StatusCode) -> ResponseError;
44130- }
44131-
44132- impl<E: Into<Box<dyn std::error::Error>>> IntoResponseError for E {
44133- fn with_status(self, status: StatusCode) -> ResponseError {
44134- ResponseError {
44135- status,
44136- ..ResponseError::from(self)
44137- }
44138- }
44139- }
44140-
44141- impl IntoResponse for ResponseError {
44142- fn into_response(self) -> axum::response::Response {
44143- let Self { inner, status } = self;
44144- (status, inner.to_string()).into_response()
44145- }
44146- }
44147-
44148- pub trait IntoResponseErrorResult<R> {
44149- fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError>;
44150- }
44151-
44152- impl<R, E> IntoResponseErrorResult<R> for std::result::Result<R, E>
44153- where
44154- E: IntoResponseError,
44155- {
44156- fn with_status(self, status: StatusCode) -> std::result::Result<R, ResponseError> {
44157- self.map_err(|err| err.with_status(status))
44158- }
44159- }
44160-
44161- #[derive(Clone)]
44162- pub struct AppState {
44163- pub conf: Configuration,
44164- pub root_url_prefix: Value,
44165- pub public_url: String,
44166- pub site_title: Cow<'static, str>,
44167- pub site_subtitle: Option<Cow<'static, str>>,
44168- pub user_store: Arc<RwLock<HashMap<i64, User>>>,
44169- // ...
44170- }
44171-
44172- mod auth_impls {
44173- use super::*;
44174- type UserId = i64;
44175- type User = auth::User;
44176- type Role = auth::Role;
44177-
44178- impl AppState {
44179- pub async fn insert_user(&self, pk: UserId, user: User) {
44180- self.user_store.write().await.insert(pk, user);
44181- }
44182- }
44183-
44184- #[axum::async_trait]
44185- impl axum_login::UserStore<UserId, Role> for Arc<AppState>
44186- where
44187- User: axum_login::AuthUser<UserId, Role>,
44188- {
44189- type User = User;
44190-
44191- async fn load_user(
44192- &self,
44193- user_id: &UserId,
44194- ) -> std::result::Result<Option<Self::User>, eyre::Report> {
44195- Ok(self.user_store.read().await.get(user_id).cloned())
44196- }
44197- }
44198- }
44199-
44200- const fn _get_package_git_sha() -> Option<&'static str> {
44201- option_env!("PACKAGE_GIT_SHA")
44202- }
44203-
44204- const _PACKAGE_COMMIT_SHA: Option<&str> = _get_package_git_sha();
44205-
44206- pub fn get_git_sha() -> std::borrow::Cow<'static, str> {
44207- if let Some(r) = _PACKAGE_COMMIT_SHA {
44208- return r.into();
44209- }
44210- build_info::build_info!(fn build_info);
44211- let info = build_info();
44212- info.version_control
44213- .as_ref()
44214- .and_then(|v| v.git())
44215- .map(|g| g.commit_short_id.clone())
44216- .map_or_else(|| "<unknown>".into(), |v| v.into())
44217- }
44218-
44219- pub const VERSION_INFO: &str = build_info::format!("{}", $.crate_info.version);
44220- pub const BUILD_INFO: &str = build_info::format!("{}\t{}\t{}\t{}", $.crate_info.version, $.compiler, $.timestamp, $.crate_info.enabled_features);
44221- pub const CLI_INFO: &str = build_info::format!("{} Version: {}\nAuthors: {}\nLicense: AGPL version 3 or later\nCompiler: {}\nBuild-Date: {}\nEnabled-features: {}", $.crate_info.name, $.crate_info.version, $.crate_info.authors, $.compiler, $.timestamp, $.crate_info.enabled_features);
44222 diff --git a/web/src/lists.rs b/web/src/lists.rs
44223deleted file mode 100644
44224index f9d130e..0000000
44225--- a/web/src/lists.rs
44226+++ /dev/null
44227 @@ -1,821 +0,0 @@
44228- /*
44229- * This file is part of mailpot
44230- *
44231- * Copyright 2020 - Manos Pitsidianakis
44232- *
44233- * This program is free software: you can redistribute it and/or modify
44234- * it under the terms of the GNU Affero General Public License as
44235- * published by the Free Software Foundation, either version 3 of the
44236- * License, or (at your option) any later version.
44237- *
44238- * This program is distributed in the hope that it will be useful,
44239- * but WITHOUT ANY WARRANTY; without even the implied warranty of
44240- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
44241- * GNU Affero General Public License for more details.
44242- *
44243- * You should have received a copy of the GNU Affero General Public License
44244- * along with this program. If not, see <https://www.gnu.org/licenses/>.
44245- */
44246-
44247- use chrono::TimeZone;
44248- use indexmap::IndexMap;
44249- use mailpot::{models::Post, StripCarets, StripCaretsInplace};
44250-
44251- use super::*;
44252-
44253- /// Mailing list index.
44254- pub async fn list(
44255- ListPath(id): ListPath,
44256- mut session: WritableSession,
44257- auth: AuthContext,
44258- State(state): State<Arc<AppState>>,
44259- ) -> Result<Html<String>, ResponseError> {
44260- let db = Connection::open_db(state.conf.clone())?;
44261- let Some(list) = (match id {
44262- ListPathIdentifier::Pk(id) => db.list(id)?,
44263- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
44264- }) else {
44265- return Err(ResponseError::new(
44266- "List not found".to_string(),
44267- StatusCode::NOT_FOUND,
44268- ));
44269- };
44270- let post_policy = db.list_post_policy(list.pk)?;
44271- let subscription_policy = db.list_subscription_policy(list.pk)?;
44272- let months = db.months(list.pk)?;
44273- let user_context = auth
44274- .current_user
44275- .as_ref()
44276- .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
44277-
44278- let posts = db.list_posts(list.pk, None)?;
44279- let post_map = posts
44280- .iter()
44281- .map(|p| (p.message_id.as_str(), p))
44282- .collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>();
44283- let mut hist = months
44284- .iter()
44285- .map(|m| (m.to_string(), [0usize; 31]))
44286- .collect::<HashMap<String, [usize; 31]>>();
44287- let envelopes: Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>> =
44288- Default::default();
44289- {
44290- let mut env_lock = envelopes.write().unwrap();
44291-
44292- for post in &posts {
44293- let Ok(mut envelope) = melib::Envelope::from_bytes(post.message.as_slice(), None)
44294- else {
44295- continue;
44296- };
44297- if envelope.message_id != post.message_id.as_str() {
44298- // If they don't match, the raw envelope doesn't contain a Message-ID and it was
44299- // randomly generated. So set the envelope's Message-ID to match the
44300- // post's, which is the "permanent" one since our source of truth is
44301- // the database.
44302- envelope.set_message_id(post.message_id.as_bytes());
44303- }
44304- env_lock.insert(envelope.hash(), envelope);
44305- }
44306- }
44307- let mut threads: melib::Threads = melib::Threads::new(posts.len());
44308- threads.amend(&envelopes);
44309- let roots = thread_roots(&envelopes, &threads);
44310- let posts_ctx = roots
44311- .into_iter()
44312- .filter_map(|(thread, length, _timestamp)| {
44313- let post = &post_map[&thread.message_id.as_str()];
44314- //2019-07-14T14:21:02
44315- if let Some(day) =
44316- chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc2822(post.datetime.trim())
44317- .ok()
44318- .map(|d| d.day())
44319- {
44320- hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
44321- }
44322- let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None).ok()?;
44323- let mut msg_id = &post.message_id[1..];
44324- msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
44325- let subject = envelope.subject();
44326- let mut subject_ref = subject.trim();
44327- if subject_ref.starts_with('[')
44328- && subject_ref[1..].starts_with(&list.id)
44329- && subject_ref[1 + list.id.len()..].starts_with(']')
44330- {
44331- subject_ref = subject_ref[2 + list.id.len()..].trim();
44332- }
44333- let ret = minijinja::context! {
44334- pk => post.pk,
44335- list => post.list,
44336- subject => subject_ref,
44337- address => post.address,
44338- message_id => msg_id,
44339- message => post.message,
44340- timestamp => post.timestamp,
44341- datetime => post.datetime,
44342- replies => length.saturating_sub(1),
44343- last_active => thread.datetime,
44344- };
44345- Some(ret)
44346- })
44347- .collect::<Vec<_>>();
44348- let crumbs = vec![
44349- Crumb {
44350- label: "Home".into(),
44351- url: "/".into(),
44352- },
44353- Crumb {
44354- label: list.name.clone().into(),
44355- url: ListPath(list.id.to_string().into()).to_crumb(),
44356- },
44357- ];
44358- let list_owners = db.list_owners(list.pk)?;
44359- let mut list_obj = MailingList::from(list.clone());
44360- list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
44361- let context = minijinja::context! {
44362- canonical_url => ListPath::from(&list).to_crumb(),
44363- page_title => &list.name,
44364- description => &list.description,
44365- post_policy,
44366- subscription_policy,
44367- preamble => true,
44368- months,
44369- hists => &hist,
44370- posts => posts_ctx,
44371- list => Value::from_object(list_obj),
44372- current_user => auth.current_user,
44373- user_context,
44374- messages => session.drain_messages(),
44375- crumbs,
44376- };
44377- Ok(Html(
44378- TEMPLATES.get_template("lists/list.html")?.render(context)?,
44379- ))
44380- }
44381-
44382- /// Mailing list post page.
44383- pub async fn list_post(
44384- ListPostPath(id, msg_id): ListPostPath,
44385- mut session: WritableSession,
44386- auth: AuthContext,
44387- State(state): State<Arc<AppState>>,
44388- ) -> Result<Html<String>, ResponseError> {
44389- let db = Connection::open_db(state.conf.clone())?.trusted();
44390- let Some(list) = (match id {
44391- ListPathIdentifier::Pk(id) => db.list(id)?,
44392- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
44393- }) else {
44394- return Err(ResponseError::new(
44395- "List not found".to_string(),
44396- StatusCode::NOT_FOUND,
44397- ));
44398- };
44399- let user_context = auth.current_user.as_ref().map(|user| {
44400- db.list_subscription_by_address(list.pk(), &user.address)
44401- .ok()
44402- });
44403-
44404- let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
44405- post
44406- } else {
44407- return Err(ResponseError::new(
44408- format!("Post with Message-ID {} not found", msg_id),
44409- StatusCode::NOT_FOUND,
44410- ));
44411- };
44412- let thread: Vec<(i64, DbVal<Post>, String, String)> = {
44413- let thread: Vec<(i64, DbVal<Post>)> = db.list_thread(list.pk, &post.message_id)?;
44414-
44415- thread
44416- .into_iter()
44417- .map(|(depth, p)| {
44418- let envelope = melib::Envelope::from_bytes(p.message.as_slice(), None).unwrap();
44419- let body = envelope.body_bytes(p.message.as_slice());
44420- let body_text = body.text();
44421- let date = envelope.date_as_str().to_string();
44422- (depth, p, body_text, date)
44423- })
44424- .collect()
44425- };
44426- let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
44427- .with_status(StatusCode::BAD_REQUEST)?;
44428- let body = envelope.body_bytes(post.message.as_slice());
44429- let body_text = body.text();
44430- let subject = envelope.subject();
44431- let mut subject_ref = subject.trim();
44432- if subject_ref.starts_with('[')
44433- && subject_ref[1..].starts_with(&list.id)
44434- && subject_ref[1 + list.id.len()..].starts_with(']')
44435- {
44436- subject_ref = subject_ref[2 + list.id.len()..].trim();
44437- }
44438- let crumbs = vec![
44439- Crumb {
44440- label: "Home".into(),
44441- url: "/".into(),
44442- },
44443- Crumb {
44444- label: list.name.clone().into(),
44445- url: ListPath(list.id.to_string().into()).to_crumb(),
44446- },
44447- Crumb {
44448- label: format!("{} {msg_id}", subject_ref).into(),
44449- url: ListPostPath(list.id.to_string().into(), msg_id.to_string()).to_crumb(),
44450- },
44451- ];
44452-
44453- let list_owners = db.list_owners(list.pk)?;
44454- let mut list_obj = MailingList::from(list.clone());
44455- list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
44456-
44457- let context = minijinja::context! {
44458- canonical_url => ListPostPath(ListPathIdentifier::from(list.id.clone()), msg_id.to_string().strip_carets_inplace()).to_crumb(),
44459- page_title => subject_ref,
44460- description => &list.description,
44461- list => Value::from_object(list_obj),
44462- pk => post.pk,
44463- body => &body_text,
44464- from => &envelope.field_from_to_string(),
44465- date => &envelope.date_as_str(),
44466- to => &envelope.field_to_to_string(),
44467- subject => &envelope.subject(),
44468- trimmed_subject => subject_ref,
44469- in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().strip_carets_inplace()),
44470- references => &envelope.references().into_iter().map(|m| m.to_string().strip_carets_inplace()).collect::<Vec<String>>(),
44471- message_id => msg_id,
44472- message => post.message,
44473- timestamp => post.timestamp,
44474- datetime => post.datetime,
44475- thread => thread,
44476- current_user => auth.current_user,
44477- user_context => user_context,
44478- messages => session.drain_messages(),
44479- crumbs => crumbs,
44480- };
44481- Ok(Html(
44482- TEMPLATES.get_template("lists/post.html")?.render(context)?,
44483- ))
44484- }
44485-
44486- pub async fn list_edit(
44487- ListEditPath(id): ListEditPath,
44488- mut session: WritableSession,
44489- auth: AuthContext,
44490- State(state): State<Arc<AppState>>,
44491- ) -> Result<Html<String>, ResponseError> {
44492- let db = Connection::open_db(state.conf.clone())?;
44493- let Some(list) = (match id {
44494- ListPathIdentifier::Pk(id) => db.list(id)?,
44495- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
44496- }) else {
44497- return Err(ResponseError::new(
44498- "Not found".to_string(),
44499- StatusCode::NOT_FOUND,
44500- ));
44501- };
44502- let list_owners = db.list_owners(list.pk)?;
44503- let user_address = &auth.current_user.as_ref().unwrap().address;
44504- if !list_owners.iter().any(|o| &o.address == user_address) {
44505- return Err(ResponseError::new(
44506- "Not found".to_string(),
44507- StatusCode::NOT_FOUND,
44508- ));
44509- };
44510-
44511- let post_policy = db.list_post_policy(list.pk)?;
44512- let subscription_policy = db.list_subscription_policy(list.pk)?;
44513- let post_count = {
44514- let mut stmt = db
44515- .connection
44516- .prepare("SELECT count(*) FROM post WHERE list = ?;")?;
44517- stmt.query_row([&list.pk], |row| {
44518- let count: usize = row.get(0)?;
44519- Ok(count)
44520- })
44521- .optional()?
44522- .unwrap_or(0)
44523- };
44524- let subs_count = {
44525- let mut stmt = db
44526- .connection
44527- .prepare("SELECT count(*) FROM subscription WHERE list = ?;")?;
44528- stmt.query_row([&list.pk], |row| {
44529- let count: usize = row.get(0)?;
44530- Ok(count)
44531- })
44532- .optional()?
44533- .unwrap_or(0)
44534- };
44535- let sub_requests_count = {
44536- let mut stmt = db.connection.prepare(
44537- "SELECT count(*) FROM candidate_subscription WHERE list = ? AND accepted IS NULL;",
44538- )?;
44539- stmt.query_row([&list.pk], |row| {
44540- let count: usize = row.get(0)?;
44541- Ok(count)
44542- })
44543- .optional()?
44544- .unwrap_or(0)
44545- };
44546-
44547- let crumbs = vec![
44548- Crumb {
44549- label: "Home".into(),
44550- url: "/".into(),
44551- },
44552- Crumb {
44553- label: list.name.clone().into(),
44554- url: ListPath(list.id.to_string().into()).to_crumb(),
44555- },
44556- Crumb {
44557- label: format!("Edit {}", list.name).into(),
44558- url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
44559- },
44560- ];
44561- let list_owners = db.list_owners(list.pk)?;
44562- let mut list_obj = MailingList::from(list.clone());
44563- list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
44564- let context = minijinja::context! {
44565- canonical_url => ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
44566- page_title => format!("Edit {} settings", list.name),
44567- description => &list.description,
44568- post_policy,
44569- subscription_policy,
44570- list_owners,
44571- post_count,
44572- subs_count,
44573- sub_requests_count,
44574- list => Value::from_object(list_obj),
44575- current_user => auth.current_user,
44576- messages => session.drain_messages(),
44577- crumbs,
44578- };
44579- Ok(Html(
44580- TEMPLATES.get_template("lists/edit.html")?.render(context)?,
44581- ))
44582- }
44583-
44584- #[allow(non_snake_case)]
44585- pub async fn list_edit_POST(
44586- ListEditPath(id): ListEditPath,
44587- mut session: WritableSession,
44588- Extension(user): Extension<User>,
44589- Form(payload): Form<ChangeSetting>,
44590- State(state): State<Arc<AppState>>,
44591- ) -> Result<Redirect, ResponseError> {
44592- let db = Connection::open_db(state.conf.clone())?;
44593- let Some(list) = (match id {
44594- ListPathIdentifier::Pk(id) => db.list(id)?,
44595- ListPathIdentifier::Id(ref id) => db.list_by_id(id)?,
44596- }) else {
44597- return Err(ResponseError::new(
44598- "Not found".to_string(),
44599- StatusCode::NOT_FOUND,
44600- ));
44601- };
44602- let list_owners = db.list_owners(list.pk)?;
44603- let user_address = &user.address;
44604- if !list_owners.iter().any(|o| &o.address == user_address) {
44605- return Err(ResponseError::new(
44606- "Not found".to_string(),
44607- StatusCode::NOT_FOUND,
44608- ));
44609- };
44610-
44611- let db = db.trusted();
44612- match payload {
44613- ChangeSetting::PostPolicy {
44614- delete_post_policy: _,
44615- post_policy: val,
44616- } => {
44617- use PostPolicySettings::*;
44618- session.add_message(
44619- if let Err(err) = db.set_list_post_policy(mailpot::models::PostPolicy {
44620- pk: -1,
44621- list: list.pk,
44622- announce_only: matches!(val, AnnounceOnly),
44623- subscription_only: matches!(val, SubscriptionOnly),
44624- approval_needed: matches!(val, ApprovalNeeded),
44625- open: matches!(val, Open),
44626- custom: matches!(val, Custom),
44627- }) {
44628- Message {
44629- message: err.to_string().into(),
44630- level: Level::Error,
44631- }
44632- } else {
44633- Message {
44634- message: "Post policy saved.".into(),
44635- level: Level::Success,
44636- }
44637- },
44638- )?;
44639- }
44640- ChangeSetting::SubscriptionPolicy {
44641- send_confirmation: BoolPOST(send_confirmation),
44642- subscription_policy: val,
44643- } => {
44644- use SubscriptionPolicySettings::*;
44645- session.add_message(
44646- if let Err(err) =
44647- db.set_list_subscription_policy(mailpot::models::SubscriptionPolicy {
44648- pk: -1,
44649- list: list.pk,
44650- send_confirmation,
44651- open: matches!(val, Open),
44652- manual: matches!(val, Manual),
44653- request: matches!(val, Request),
44654- custom: matches!(val, Custom),
44655- })
44656- {
44657- Message {
44658- message: err.to_string().into(),
44659- level: Level::Error,
44660- }
44661- } else {
44662- Message {
44663- message: "Subscription policy saved.".into(),
44664- level: Level::Success,
44665- }
44666- },
44667- )?;
44668- }
44669- ChangeSetting::Metadata {
44670- name,
44671- id,
44672- address,
44673- description,
44674- owner_local_part,
44675- request_local_part,
44676- archive_url,
44677- } => {
44678- session.add_message(
44679- if let Err(err) =
44680- db.update_list(mailpot::models::changesets::MailingListChangeset {
44681- pk: list.pk,
44682- name: Some(name),
44683- id: Some(id),
44684- address: Some(address),
44685- description: description.map(|s| if s.is_empty() { None } else { Some(s) }),
44686- owner_local_part: owner_local_part.map(|s| {
44687- if s.is_empty() {
44688- None
44689- } else {
44690- Some(s)
44691- }
44692- }),
44693- request_local_part: request_local_part.map(|s| {
44694- if s.is_empty() {
44695- None
44696- } else {
44697- Some(s)
44698- }
44699- }),
44700- archive_url: archive_url.map(|s| if s.is_empty() { None } else { Some(s) }),
44701- ..Default::default()
44702- })
44703- {
44704- Message {
44705- message: err.to_string().into(),
44706- level: Level::Error,
44707- }
44708- } else {
44709- Message {
44710- message: "List metadata saved.".into(),
44711- level: Level::Success,
44712- }
44713- },
44714- )?;
44715- }
44716- ChangeSetting::AcceptSubscriptionRequest { pk: IntPOST(pk) } => {
44717- session.add_message(match db.accept_candidate_subscription(pk) {
44718- Ok(subscription) => Message {
44719- message: format!("Added: {subscription:#?}").into(),
44720- level: Level::Success,
44721- },
44722- Err(err) => Message {
44723- message: format!("Could not accept subscription request! Reason: {err}").into(),
44724- level: Level::Error,
44725- },
44726- })?;
44727- }
44728- }
44729-
44730- Ok(Redirect::to(&format!(
44731- "{}{}",
44732- &state.root_url_prefix,
44733- ListEditPath(id).to_uri()
44734- )))
44735- }
44736-
44737- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
44738- #[serde(tag = "type", rename_all = "kebab-case")]
44739- pub enum ChangeSetting {
44740- PostPolicy {
44741- #[serde(rename = "delete-post-policy", default)]
44742- delete_post_policy: Option<String>,
44743- #[serde(rename = "post-policy")]
44744- post_policy: PostPolicySettings,
44745- },
44746- SubscriptionPolicy {
44747- #[serde(rename = "send-confirmation", default)]
44748- send_confirmation: BoolPOST,
44749- #[serde(rename = "subscription-policy")]
44750- subscription_policy: SubscriptionPolicySettings,
44751- },
44752- Metadata {
44753- name: String,
44754- id: String,
44755- #[serde(default)]
44756- address: String,
44757- #[serde(default)]
44758- description: Option<String>,
44759- #[serde(rename = "owner-local-part")]
44760- #[serde(default)]
44761- owner_local_part: Option<String>,
44762- #[serde(rename = "request-local-part")]
44763- #[serde(default)]
44764- request_local_part: Option<String>,
44765- #[serde(rename = "archive-url")]
44766- #[serde(default)]
44767- archive_url: Option<String>,
44768- },
44769- AcceptSubscriptionRequest {
44770- pk: IntPOST,
44771- },
44772- }
44773-
44774- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
44775- #[serde(rename_all = "kebab-case")]
44776- pub enum PostPolicySettings {
44777- AnnounceOnly,
44778- SubscriptionOnly,
44779- ApprovalNeeded,
44780- Open,
44781- Custom,
44782- }
44783-
44784- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
44785- #[serde(rename_all = "kebab-case")]
44786- pub enum SubscriptionPolicySettings {
44787- Open,
44788- Manual,
44789- Request,
44790- Custom,
44791- }
44792-
44793- /// Raw post page.
44794- pub async fn list_post_raw(
44795- ListPostRawPath(id, msg_id): ListPostRawPath,
44796- State(state): State<Arc<AppState>>,
44797- ) -> Result<String, ResponseError> {
44798- let db = Connection::open_db(state.conf.clone())?.trusted();
44799- let Some(list) = (match id {
44800- ListPathIdentifier::Pk(id) => db.list(id)?,
44801- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
44802- }) else {
44803- return Err(ResponseError::new(
44804- "List not found".to_string(),
44805- StatusCode::NOT_FOUND,
44806- ));
44807- };
44808-
44809- let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
44810- post
44811- } else {
44812- return Err(ResponseError::new(
44813- format!("Post with Message-ID {} not found", msg_id),
44814- StatusCode::NOT_FOUND,
44815- ));
44816- };
44817- Ok(String::from_utf8_lossy(&post.message).to_string())
44818- }
44819-
44820- /// .eml post page.
44821- pub async fn list_post_eml(
44822- ListPostEmlPath(id, msg_id): ListPostEmlPath,
44823- State(state): State<Arc<AppState>>,
44824- ) -> Result<impl IntoResponse, ResponseError> {
44825- let db = Connection::open_db(state.conf.clone())?.trusted();
44826- let Some(list) = (match id {
44827- ListPathIdentifier::Pk(id) => db.list(id)?,
44828- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
44829- }) else {
44830- return Err(ResponseError::new(
44831- "List not found".to_string(),
44832- StatusCode::NOT_FOUND,
44833- ));
44834- };
44835-
44836- let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
44837- post
44838- } else {
44839- return Err(ResponseError::new(
44840- format!("Post with Message-ID {} not found", msg_id),
44841- StatusCode::NOT_FOUND,
44842- ));
44843- };
44844- let mut response = post.into_inner().message.into_response();
44845- response.headers_mut().insert(
44846- http::header::CONTENT_TYPE,
44847- http::HeaderValue::from_static("application/octet-stream"),
44848- );
44849- response.headers_mut().insert(
44850- http::header::CONTENT_DISPOSITION,
44851- http::HeaderValue::try_from(format!(
44852- "attachment; filename=\"{}.eml\"",
44853- msg_id.trim().strip_carets()
44854- ))
44855- .unwrap(),
44856- );
44857-
44858- Ok(response)
44859- }
44860-
44861- pub async fn list_subscribers(
44862- ListEditSubscribersPath(id): ListEditSubscribersPath,
44863- mut session: WritableSession,
44864- auth: AuthContext,
44865- State(state): State<Arc<AppState>>,
44866- ) -> Result<Html<String>, ResponseError> {
44867- let db = Connection::open_db(state.conf.clone())?;
44868- let Some(list) = (match id {
44869- ListPathIdentifier::Pk(id) => db.list(id)?,
44870- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
44871- }) else {
44872- return Err(ResponseError::new(
44873- "Not found".to_string(),
44874- StatusCode::NOT_FOUND,
44875- ));
44876- };
44877- let list_owners = db.list_owners(list.pk)?;
44878- let user_address = &auth.current_user.as_ref().unwrap().address;
44879- if !list_owners.iter().any(|o| &o.address == user_address) {
44880- return Err(ResponseError::new(
44881- "Not found".to_string(),
44882- StatusCode::NOT_FOUND,
44883- ));
44884- };
44885-
44886- let subs = {
44887- let mut stmt = db
44888- .connection
44889- .prepare("SELECT * FROM subscription WHERE list = ?;")?;
44890- let iter = stmt.query_map([&list.pk], |row| {
44891- let address: String = row.get("address")?;
44892- let name: Option<String> = row.get("name")?;
44893- let enabled: bool = row.get("enabled")?;
44894- let verified: bool = row.get("verified")?;
44895- let digest: bool = row.get("digest")?;
44896- let hide_address: bool = row.get("hide_address")?;
44897- let receive_duplicates: bool = row.get("receive_duplicates")?;
44898- let receive_own_posts: bool = row.get("receive_own_posts")?;
44899- let receive_confirmation: bool = row.get("receive_confirmation")?;
44900- //let last_digest: i64 = row.get("last_digest")?;
44901- let created: i64 = row.get("created")?;
44902- let last_modified: i64 = row.get("last_modified")?;
44903- Ok(minijinja::context! {
44904- address,
44905- name,
44906- enabled,
44907- verified,
44908- digest,
44909- hide_address,
44910- receive_duplicates,
44911- receive_own_posts,
44912- receive_confirmation,
44913- //last_digest => chrono::Utc.timestamp_opt(last_digest, 0).unwrap().to_string(),
44914- created => chrono::Utc.timestamp_opt(created, 0).unwrap().to_string(),
44915- last_modified => chrono::Utc.timestamp_opt(last_modified, 0).unwrap().to_string(),
44916- })
44917- })?;
44918- let mut ret = vec![];
44919- for el in iter {
44920- let el = el?;
44921- ret.push(el);
44922- }
44923- ret
44924- };
44925-
44926- let crumbs = vec![
44927- Crumb {
44928- label: "Home".into(),
44929- url: "/".into(),
44930- },
44931- Crumb {
44932- label: list.name.clone().into(),
44933- url: ListPath(list.id.to_string().into()).to_crumb(),
44934- },
44935- Crumb {
44936- label: format!("Edit {}", list.name).into(),
44937- url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
44938- },
44939- Crumb {
44940- label: format!("Subscribers of {}", list.name).into(),
44941- url: ListEditSubscribersPath(list.id.to_string().into()).to_crumb(),
44942- },
44943- ];
44944- let list_owners = db.list_owners(list.pk)?;
44945- let mut list_obj = MailingList::from(list.clone());
44946- list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
44947- let context = minijinja::context! {
44948- canonical_url => ListEditSubscribersPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
44949- page_title => format!("Subscribers of {}", list.name),
44950- subs,
44951- list => Value::from_object(list_obj),
44952- current_user => auth.current_user,
44953- messages => session.drain_messages(),
44954- crumbs,
44955- };
44956- Ok(Html(
44957- TEMPLATES.get_template("lists/subs.html")?.render(context)?,
44958- ))
44959- }
44960-
44961- pub async fn list_candidates(
44962- ListEditCandidatesPath(id): ListEditCandidatesPath,
44963- mut session: WritableSession,
44964- auth: AuthContext,
44965- State(state): State<Arc<AppState>>,
44966- ) -> Result<Html<String>, ResponseError> {
44967- let db = Connection::open_db(state.conf.clone())?;
44968- let Some(list) = (match id {
44969- ListPathIdentifier::Pk(id) => db.list(id)?,
44970- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
44971- }) else {
44972- return Err(ResponseError::new(
44973- "Not found".to_string(),
44974- StatusCode::NOT_FOUND,
44975- ));
44976- };
44977- let list_owners = db.list_owners(list.pk)?;
44978- let user_address = &auth.current_user.as_ref().unwrap().address;
44979- if !list_owners.iter().any(|o| &o.address == user_address) {
44980- return Err(ResponseError::new(
44981- "Not found".to_string(),
44982- StatusCode::NOT_FOUND,
44983- ));
44984- };
44985-
44986- let subs = {
44987- let mut stmt = db
44988- .connection
44989- .prepare("SELECT * FROM candidate_subscription WHERE list = ?;")?;
44990- let iter = stmt.query_map([&list.pk], |row| {
44991- let pk: i64 = row.get("pk")?;
44992- let address: String = row.get("address")?;
44993- let name: Option<String> = row.get("name")?;
44994- let accepted: Option<i64> = row.get("accepted")?;
44995- let created: i64 = row.get("created")?;
44996- let last_modified: i64 = row.get("last_modified")?;
44997- Ok(minijinja::context! {
44998- pk,
44999- address,
45000- name,
45001- accepted => accepted.is_some(),
45002- created => chrono::Utc.timestamp_opt(created, 0).unwrap().to_string(),
45003- last_modified => chrono::Utc.timestamp_opt(last_modified, 0).unwrap().to_string(),
45004- })
45005- })?;
45006- let mut ret = vec![];
45007- for el in iter {
45008- let el = el?;
45009- ret.push(el);
45010- }
45011- ret
45012- };
45013-
45014- let crumbs = vec![
45015- Crumb {
45016- label: "Home".into(),
45017- url: "/".into(),
45018- },
45019- Crumb {
45020- label: list.name.clone().into(),
45021- url: ListPath(list.id.to_string().into()).to_crumb(),
45022- },
45023- Crumb {
45024- label: format!("Edit {}", list.name).into(),
45025- url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
45026- },
45027- Crumb {
45028- label: format!("Requests of {}", list.name).into(),
45029- url: ListEditCandidatesPath(list.id.to_string().into()).to_crumb(),
45030- },
45031- ];
45032- let mut list_obj: MailingList = MailingList::from(list.clone());
45033- list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
45034- let context = minijinja::context! {
45035- canonical_url => ListEditCandidatesPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
45036- page_title => format!("Requests of {}", list.name),
45037- subs,
45038- list => Value::from_object(list_obj),
45039- current_user => auth.current_user,
45040- messages => session.drain_messages(),
45041- crumbs,
45042- };
45043- Ok(Html(
45044- TEMPLATES
45045- .get_template("lists/sub-requests.html")?
45046- .render(context)?,
45047- ))
45048- }
45049 diff --git a/web/src/main.rs b/web/src/main.rs
45050deleted file mode 100644
45051index 0882abc..0000000
45052--- a/web/src/main.rs
45053+++ /dev/null
45054 @@ -1,554 +0,0 @@
45055- /*
45056- * This file is part of mailpot
45057- *
45058- * Copyright 2020 - Manos Pitsidianakis
45059- *
45060- * This program is free software: you can redistribute it and/or modify
45061- * it under the terms of the GNU Affero General Public License as
45062- * published by the Free Software Foundation, either version 3 of the
45063- * License, or (at your option) any later version.
45064- *
45065- * This program is distributed in the hope that it will be useful,
45066- * but WITHOUT ANY WARRANTY; without even the implied warranty of
45067- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
45068- * GNU Affero General Public License for more details.
45069- *
45070- * You should have received a copy of the GNU Affero General Public License
45071- * along with this program. If not, see <https://www.gnu.org/licenses/>.
45072- */
45073-
45074- use std::{collections::HashMap, sync::Arc};
45075-
45076- use chrono::TimeZone;
45077- use mailpot::{log, Configuration, Connection};
45078- use mailpot_web::*;
45079- use minijinja::value::Value;
45080- use rand::Rng;
45081- use tokio::sync::RwLock;
45082-
45083- fn new_state(conf: Configuration) -> Arc<AppState> {
45084- Arc::new(AppState {
45085- conf,
45086- root_url_prefix: Value::from_safe_string(
45087- std::env::var("ROOT_URL_PREFIX").unwrap_or_default(),
45088- ),
45089- public_url: std::env::var("PUBLIC_URL").unwrap_or_else(|_| "lists.mailpot.rs".to_string()),
45090- site_title: std::env::var("SITE_TITLE")
45091- .unwrap_or_else(|_| "mailing list archive".to_string())
45092- .into(),
45093- site_subtitle: std::env::var("SITE_SUBTITLE").ok().map(Into::into),
45094- user_store: Arc::new(RwLock::new(HashMap::default())),
45095- })
45096- }
45097-
45098- fn create_app(shared_state: Arc<AppState>) -> Router {
45099- let store = MemoryStore::new();
45100- let secret = rand::thread_rng().gen::<[u8; 128]>();
45101- let session_layer = SessionLayer::new(store, &secret).with_secure(false);
45102-
45103- let auth_layer = AuthLayer::new(shared_state.clone(), &secret);
45104-
45105- let login_url =
45106- Arc::new(format!("{}{}", shared_state.root_url_prefix, LoginPath.to_crumb()).into());
45107- Router::new()
45108- .route("/", get(root))
45109- .typed_get(list)
45110- .typed_get(list_post)
45111- .typed_get(list_post_raw)
45112- .typed_get(list_topics)
45113- .typed_get(list_post_eml)
45114- .typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect(
45115- Role::User..,
45116- Arc::clone(&login_url),
45117- Some(Arc::new("next".into())),
45118- )))
45119- .typed_post(
45120- {
45121- let shared_state = Arc::clone(&shared_state);
45122- move |path, session, user, payload| {
45123- list_edit_POST(path, session, user, payload, State(shared_state))
45124- }
45125- }
45126- .layer(RequireAuth::login_with_role_or_redirect(
45127- Role::User..,
45128- Arc::clone(&login_url),
45129- Some(Arc::new("next".into())),
45130- )),
45131- )
45132- .typed_get(
45133- list_subscribers.layer(RequireAuth::login_with_role_or_redirect(
45134- Role::User..,
45135- Arc::clone(&login_url),
45136- Some(Arc::new("next".into())),
45137- )),
45138- )
45139- .typed_get(
45140- list_candidates.layer(RequireAuth::login_with_role_or_redirect(
45141- Role::User..,
45142- Arc::clone(&login_url),
45143- Some(Arc::new("next".into())),
45144- )),
45145- )
45146- .typed_get(help)
45147- .typed_get(auth::ssh_signin)
45148- .typed_post({
45149- let shared_state = Arc::clone(&shared_state);
45150- move |path, session, query, auth, body| {
45151- auth::ssh_signin_POST(path, session, query, auth, body, shared_state)
45152- }
45153- })
45154- .typed_get(logout_handler)
45155- .typed_post(logout_handler)
45156- .typed_get(
45157- {
45158- let shared_state = Arc::clone(&shared_state);
45159- move |path, session, user| settings(path, session, user, shared_state)
45160- }
45161- .layer(RequireAuth::login_or_redirect(
45162- Arc::clone(&login_url),
45163- Some(Arc::new("next".into())),
45164- )),
45165- )
45166- .typed_post(
45167- {
45168- let shared_state = Arc::clone(&shared_state);
45169- move |path, session, auth, body| {
45170- settings_POST(path, session, auth, body, shared_state)
45171- }
45172- }
45173- .layer(RequireAuth::login_or_redirect(
45174- Arc::clone(&login_url),
45175- Some(Arc::new("next".into())),
45176- )),
45177- )
45178- .typed_get(
45179- user_list_subscription.layer(RequireAuth::login_with_role_or_redirect(
45180- Role::User..,
45181- Arc::clone(&login_url),
45182- Some(Arc::new("next".into())),
45183- )),
45184- )
45185- .typed_post(
45186- {
45187- let shared_state = Arc::clone(&shared_state);
45188- move |session, path, user, body| {
45189- user_list_subscription_POST(session, path, user, body, shared_state)
45190- }
45191- }
45192- .layer(RequireAuth::login_with_role_or_redirect(
45193- Role::User..,
45194- Arc::clone(&login_url),
45195- Some(Arc::new("next".into())),
45196- )),
45197- )
45198- .layer(auth_layer)
45199- .layer(session_layer)
45200- .with_state(shared_state)
45201- }
45202-
45203- #[tokio::main]
45204- async fn main() {
45205- let config_path = std::env::args()
45206- .nth(1)
45207- .expect("Expected configuration file path as first argument.");
45208- if ["-v", "--version", "info"].contains(&config_path.as_str()) {
45209- println!("{}", crate::get_git_sha());
45210- println!("{CLI_INFO}");
45211-
45212- return;
45213- }
45214- #[cfg(test)]
45215- let verbosity = log::LevelFilter::Trace;
45216- #[cfg(not(test))]
45217- let verbosity = log::LevelFilter::Info;
45218- stderrlog::new()
45219- .quiet(false)
45220- .verbosity(verbosity)
45221- .show_module_names(true)
45222- .timestamp(stderrlog::Timestamp::Millisecond)
45223- .init()
45224- .unwrap();
45225- let conf = Configuration::from_file(config_path).unwrap();
45226- let app = create_app(new_state(conf));
45227-
45228- let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "0.0.0.0".to_string());
45229- let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
45230- let listen_to = format!("{hostname}:{port}");
45231- println!("Listening to {listen_to}...");
45232- axum::Server::bind(&listen_to.parse().unwrap())
45233- .serve(app.into_make_service())
45234- .await
45235- .unwrap();
45236- }
45237-
45238- async fn root(
45239- mut session: WritableSession,
45240- auth: AuthContext,
45241- State(state): State<Arc<AppState>>,
45242- ) -> Result<Html<String>, ResponseError> {
45243- let db = Connection::open_db(state.conf.clone())?;
45244- let lists_values = db.lists()?;
45245- let lists = lists_values
45246- .iter()
45247- .map(|list| {
45248- let months = db.months(list.pk)?;
45249- let posts = db.list_posts(list.pk, None)?;
45250- let newest = posts.last().and_then(|p| {
45251- chrono::Utc
45252- .timestamp_opt(p.timestamp as i64, 0)
45253- .earliest()
45254- .map(|d| d.to_rfc3339())
45255- });
45256- let list_owners = db.list_owners(list.pk)?;
45257- let mut list_obj = MailingList::from(list.clone());
45258- list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
45259- Ok(minijinja::context! {
45260- newest,
45261- posts => &posts,
45262- months => &months,
45263- list => Value::from_object(list_obj),
45264- })
45265- })
45266- .collect::<Result<Vec<_>, mailpot::Error>>()?;
45267- let crumbs = vec![Crumb {
45268- label: "Home".into(),
45269- url: "/".into(),
45270- }];
45271-
45272- let context = minijinja::context! {
45273- page_title => Option::<&'static str>::None,
45274- lists => &lists,
45275- current_user => auth.current_user,
45276- messages => session.drain_messages(),
45277- crumbs => crumbs,
45278- };
45279- Ok(Html(TEMPLATES.get_template("lists.html")?.render(context)?))
45280- }
45281-
45282- #[cfg(test)]
45283- mod tests {
45284-
45285- use axum::{
45286- body::Body,
45287- http::{
45288- header::{COOKIE, SET_COOKIE},
45289- method::Method,
45290- Request, StatusCode,
45291- },
45292- };
45293- use mailpot::{Configuration, Connection, SendMail};
45294- use mailpot_tests::init_stderr_logging;
45295- use percent_encoding::utf8_percent_encode;
45296- use tempfile::TempDir;
45297- use tower::ServiceExt;
45298-
45299- use super::*;
45300-
45301- #[tokio::test]
45302- async fn test_routes() {
45303- #![cfg_attr(not(debug_assertions), allow(unreachable_code))]
45304-
45305- init_stderr_logging();
45306-
45307- macro_rules! req {
45308- (get $url:expr) => {{
45309- Request::builder()
45310- .uri($url)
45311- .method(Method::GET)
45312- .body(Body::empty())
45313- .unwrap()
45314- }};
45315- (post $url:expr, $body:expr) => {{
45316- Request::builder()
45317- .uri($url)
45318- .method(Method::POST)
45319- .header("Content-Type", "application/x-www-form-urlencoded")
45320- .body(Body::from(
45321- serde_urlencoded::to_string($body).unwrap().into_bytes(),
45322- ))
45323- .unwrap()
45324- }};
45325- }
45326-
45327- let tmp_dir = TempDir::new().unwrap();
45328-
45329- let db_path = tmp_dir.path().join("mpot.db");
45330- std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
45331- let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
45332- #[allow(clippy::permissions_set_readonly_false)]
45333- perms.set_readonly(false);
45334- std::fs::set_permissions(&db_path, perms).unwrap();
45335-
45336- let config = Configuration {
45337- send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
45338- db_path,
45339- data_path: tmp_dir.path().to_path_buf(),
45340- administrators: vec![],
45341- };
45342- let db = Connection::open_db(config.clone()).unwrap();
45343- let list = db.lists().unwrap().remove(0);
45344-
45345- let state = new_state(config.clone());
45346-
45347- // ------------------------------------------------------------
45348- // list()
45349-
45350- let cl = |url, state| async move {
45351- let response = create_app(state).oneshot(req!(get & url)).await.unwrap();
45352-
45353- assert_eq!(response.status(), StatusCode::OK);
45354-
45355- hyper::body::to_bytes(response.into_body()).await.unwrap()
45356- };
45357- assert_eq!(
45358- cl(format!("/list/{}/", list.id), state.clone()).await,
45359- cl(format!("/list/{}/", list.pk), state.clone()).await
45360- );
45361-
45362- // ------------------------------------------------------------
45363- // list_post(), list_post_eml(), list_post_raw()
45364-
45365- {
45366- let msg_id = "<abcdefgh@sator.example.com>";
45367- let res = create_app(state.clone())
45368- .oneshot(req!(
45369- get & format!(
45370- "/list/{id}/posts/{msgid}/",
45371- id = list.id,
45372- msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
45373- )
45374- ))
45375- .await
45376- .unwrap();
45377-
45378- assert_eq!(res.status(), StatusCode::OK);
45379- assert_eq!(
45380- res.headers().get(http::header::CONTENT_TYPE),
45381- Some(&http::HeaderValue::from_static("text/html; charset=utf-8"))
45382- );
45383- let res = create_app(state.clone())
45384- .oneshot(req!(
45385- get & format!(
45386- "/list/{id}/posts/{msgid}/raw/",
45387- id = list.id,
45388- msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
45389- )
45390- ))
45391- .await
45392- .unwrap();
45393-
45394- assert_eq!(res.status(), StatusCode::OK);
45395- assert_eq!(
45396- res.headers().get(http::header::CONTENT_TYPE),
45397- Some(&http::HeaderValue::from_static("text/plain; charset=utf-8"))
45398- );
45399- let res = create_app(state.clone())
45400- .oneshot(req!(
45401- get & format!(
45402- "/list/{id}/posts/{msgid}/eml/",
45403- id = list.id,
45404- msgid = utf8_percent_encode(msg_id, PATH_SEGMENT)
45405- )
45406- ))
45407- .await
45408- .unwrap();
45409-
45410- assert_eq!(res.status(), StatusCode::OK);
45411- assert_eq!(
45412- res.headers().get(http::header::CONTENT_TYPE),
45413- Some(&http::HeaderValue::from_static("application/octet-stream"))
45414- );
45415- assert_eq!(
45416- res.headers().get(http::header::CONTENT_DISPOSITION),
45417- Some(&http::HeaderValue::from_static(
45418- "attachment; filename=\"abcdefgh@sator.example.com.eml\""
45419- )),
45420- );
45421- }
45422- // ------------------------------------------------------------
45423- // help(), ssh_signin(), root()
45424-
45425- for path in ["/help/", "/"] {
45426- let response = create_app(state.clone())
45427- .oneshot(req!(get path))
45428- .await
45429- .unwrap();
45430-
45431- assert_eq!(response.status(), StatusCode::OK);
45432- }
45433-
45434- #[cfg(not(debug_assertions))]
45435- return;
45436- // ------------------------------------------------------------
45437- // auth.rs...
45438-
45439- let login_app = create_app(state.clone());
45440- let session_cookie = {
45441- let response = login_app
45442- .clone()
45443- .oneshot(req!(get "/login/"))
45444- .await
45445- .unwrap();
45446- assert_eq!(response.status(), StatusCode::OK);
45447-
45448- response.headers().get(SET_COOKIE).unwrap().clone()
45449- };
45450- let user = User {
45451- pk: 1,
45452- ssh_signature: String::new(),
45453- role: Role::User,
45454- public_key: None,
45455- password: String::new(),
45456- name: None,
45457- address: String::new(),
45458- enabled: true,
45459- };
45460- state.insert_user(1, user.clone()).await;
45461-
45462- {
45463- let mut request = req!(post "/login/",
45464- AuthFormPayload {
45465- address: "user@example.com".into(),
45466- password: "hunter2".into()
45467- }
45468- );
45469- request
45470- .headers_mut()
45471- .insert(COOKIE, session_cookie.to_owned());
45472- let res = login_app.clone().oneshot(request).await.unwrap();
45473-
45474- assert_eq!(
45475- res.headers().get(http::header::LOCATION),
45476- Some(
45477- &SettingsPath
45478- .to_uri()
45479- .to_string()
45480- .as_str()
45481- .try_into()
45482- .unwrap()
45483- )
45484- );
45485- }
45486-
45487- // ------------------------------------------------------------
45488- // settings()
45489-
45490- {
45491- let mut request = req!(get "/settings/");
45492- request
45493- .headers_mut()
45494- .insert(COOKIE, session_cookie.to_owned());
45495- let response = login_app.clone().oneshot(request).await.unwrap();
45496-
45497- assert_eq!(response.status(), StatusCode::OK);
45498- }
45499-
45500- // ------------------------------------------------------------
45501- // settings_post()
45502-
45503- {
45504- let mut request = req!(
45505- post "/settings/",
45506- crate::settings::ChangeSetting::Subscribe {
45507- list_pk: IntPOST(1),
45508- });
45509- request
45510- .headers_mut()
45511- .insert(COOKIE, session_cookie.to_owned());
45512- let res = login_app.clone().oneshot(request).await.unwrap();
45513-
45514- assert_eq!(
45515- res.headers().get(http::header::LOCATION),
45516- Some(
45517- &SettingsPath
45518- .to_uri()
45519- .to_string()
45520- .as_str()
45521- .try_into()
45522- .unwrap()
45523- )
45524- );
45525- }
45526- // ------------------------------------------------------------
45527- // user_list_subscription() TODO
45528-
45529- // ------------------------------------------------------------
45530- // user_list_subscription_post() TODO
45531-
45532- // ------------------------------------------------------------
45533- // list_edit()
45534-
45535- {
45536- let mut request = req!(get & format!("/list/{id}/edit/", id = list.id,));
45537- request
45538- .headers_mut()
45539- .insert(COOKIE, session_cookie.to_owned());
45540- let response = login_app.clone().oneshot(request).await.unwrap();
45541-
45542- assert_eq!(response.status(), StatusCode::OK);
45543- }
45544-
45545- // ------------------------------------------------------------
45546- // list_edit_POST()
45547-
45548- {
45549- let mut request = req!(
45550- post & format!("/list/{id}/edit/", id = list.id,),
45551- crate::lists::ChangeSetting::Metadata {
45552- name: "new name".to_string(),
45553- id: "new-name".to_string(),
45554- address: list.address.clone(),
45555- description: list.description.clone(),
45556- owner_local_part: None,
45557- request_local_part: None,
45558- archive_url: None,
45559- }
45560- );
45561- request
45562- .headers_mut()
45563- .insert(COOKIE, session_cookie.to_owned());
45564- let response = login_app.clone().oneshot(request).await.unwrap();
45565-
45566- assert_eq!(response.status(), StatusCode::SEE_OTHER);
45567- let list_mod = db.lists().unwrap().remove(0);
45568- assert_eq!(&list_mod.name, "new name");
45569- assert_eq!(&list_mod.id, "new-name");
45570- assert_eq!(&list_mod.address, &list.address);
45571- assert_eq!(&list_mod.description, &list.description);
45572- }
45573-
45574- {
45575- let mut request = req!(post "/list/new-name/edit/",
45576- crate::lists::ChangeSetting::SubscriptionPolicy {
45577- send_confirmation: BoolPOST(false),
45578- subscription_policy: crate::lists::SubscriptionPolicySettings::Custom,
45579- }
45580- );
45581- request
45582- .headers_mut()
45583- .insert(COOKIE, session_cookie.to_owned());
45584- let response = login_app.clone().oneshot(request).await.unwrap();
45585-
45586- assert_eq!(response.status(), StatusCode::SEE_OTHER);
45587- let policy = db.list_subscription_policy(list.pk()).unwrap().unwrap();
45588- assert!(!policy.send_confirmation);
45589- assert!(policy.custom);
45590- }
45591- {
45592- let mut request = req!(post "/list/new-name/edit/",
45593- crate::lists::ChangeSetting::PostPolicy {
45594- delete_post_policy: None,
45595- post_policy: crate::lists::PostPolicySettings::Custom,
45596- }
45597- );
45598- request
45599- .headers_mut()
45600- .insert(COOKIE, session_cookie.to_owned());
45601- let response = login_app.clone().oneshot(request).await.unwrap();
45602-
45603- assert_eq!(response.status(), StatusCode::SEE_OTHER);
45604- let policy = db.list_post_policy(list.pk()).unwrap().unwrap();
45605- assert!(policy.custom);
45606- }
45607- }
45608- }
45609 diff --git a/web/src/minijinja_utils.rs b/web/src/minijinja_utils.rs
45610deleted file mode 100644
45611index 5238343..0000000
45612--- a/web/src/minijinja_utils.rs
45613+++ /dev/null
45614 @@ -1,893 +0,0 @@
45615- /*
45616- * This file is part of mailpot
45617- *
45618- * Copyright 2020 - Manos Pitsidianakis
45619- *
45620- * This program is free software: you can redistribute it and/or modify
45621- * it under the terms of the GNU Affero General Public License as
45622- * published by the Free Software Foundation, either version 3 of the
45623- * License, or (at your option) any later version.
45624- *
45625- * This program is distributed in the hope that it will be useful,
45626- * but WITHOUT ANY WARRANTY; without even the implied warranty of
45627- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
45628- * GNU Affero General Public License for more details.
45629- *
45630- * You should have received a copy of the GNU Affero General Public License
45631- * along with this program. If not, see <https://www.gnu.org/licenses/>.
45632- */
45633-
45634- //! Utils for templates with the [`minijinja`] crate.
45635-
45636- use std::fmt::Write;
45637-
45638- use mailpot::models::ListOwner;
45639- pub use mailpot::StripCarets;
45640-
45641- use super::*;
45642-
45643- mod compressed;
45644-
45645- lazy_static::lazy_static! {
45646- pub static ref TEMPLATES: Environment<'static> = {
45647- let mut env = Environment::new();
45648- macro_rules! add {
45649- (function $($id:ident),*$(,)?) => {
45650- $(env.add_function(stringify!($id), $id);)*
45651- };
45652- (filter $($id:ident),*$(,)?) => {
45653- $(env.add_filter(stringify!($id), $id);)*
45654- }
45655- }
45656- add!(function calendarize,
45657- strip_carets,
45658- urlize,
45659- heading,
45660- topics,
45661- login_path,
45662- logout_path,
45663- settings_path,
45664- help_path,
45665- list_path,
45666- list_settings_path,
45667- list_edit_path,
45668- list_subscribers_path,
45669- list_candidates_path,
45670- list_post_path,
45671- post_raw_path,
45672- post_eml_path
45673- );
45674- add!(filter pluralize);
45675- // Load compressed templates. They are constructed in build.rs. See
45676- // [ref:embed_templates]
45677- let mut source = minijinja::Source::new();
45678- for (name, bytes) in compressed::COMPRESSED {
45679- let mut de_bytes = vec![];
45680- zstd::stream::copy_decode(*bytes,&mut de_bytes).unwrap();
45681- source.add_template(*name, String::from_utf8(de_bytes).unwrap()).unwrap();
45682- }
45683- env.set_source(source);
45684-
45685- env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
45686- env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
45687- env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
45688- env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
45689-
45690- env
45691- };
45692- }
45693-
45694- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
45695- pub struct MailingList {
45696- pub pk: i64,
45697- pub name: String,
45698- pub id: String,
45699- pub address: String,
45700- pub description: Option<String>,
45701- pub topics: Vec<String>,
45702- #[serde(serialize_with = "super::utils::to_safe_string_opt")]
45703- pub archive_url: Option<String>,
45704- pub inner: DbVal<mailpot::models::MailingList>,
45705- #[serde(default)]
45706- pub is_description_html_safe: bool,
45707- }
45708-
45709- impl MailingList {
45710- /// Set whether it's safe to not escape the list's description field.
45711- ///
45712- /// If anyone can display arbitrary html in the server, that's bad.
45713- ///
45714- /// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
45715- /// `ListOwner` slices.
45716- pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
45717- &mut self,
45718- owners: &[O],
45719- administrators: &[String],
45720- ) {
45721- if owners.is_empty() || administrators.is_empty() {
45722- return;
45723- }
45724- self.is_description_html_safe = owners
45725- .iter()
45726- .any(|o| administrators.contains(&o.borrow().address));
45727- }
45728- }
45729-
45730- impl From<DbVal<mailpot::models::MailingList>> for MailingList {
45731- fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
45732- let DbVal(
45733- mailpot::models::MailingList {
45734- pk,
45735- name,
45736- id,
45737- address,
45738- description,
45739- topics,
45740- archive_url,
45741- },
45742- _,
45743- ) = val.clone();
45744-
45745- Self {
45746- pk,
45747- name,
45748- id,
45749- address,
45750- description,
45751- topics,
45752- archive_url,
45753- inner: val,
45754- is_description_html_safe: false,
45755- }
45756- }
45757- }
45758-
45759- impl std::fmt::Display for MailingList {
45760- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
45761- self.id.fmt(fmt)
45762- }
45763- }
45764-
45765- impl Object for MailingList {
45766- fn kind(&self) -> minijinja::value::ObjectKind {
45767- minijinja::value::ObjectKind::Struct(self)
45768- }
45769-
45770- fn call_method(
45771- &self,
45772- _state: &minijinja::State,
45773- name: &str,
45774- _args: &[Value],
45775- ) -> std::result::Result<Value, Error> {
45776- match name {
45777- "subscription_mailto" => {
45778- Ok(Value::from_serializable(&self.inner.subscription_mailto()))
45779- }
45780- "unsubscription_mailto" => Ok(Value::from_serializable(
45781- &self.inner.unsubscription_mailto(),
45782- )),
45783- "topics" => topics_common(&self.topics),
45784- _ => Err(Error::new(
45785- minijinja::ErrorKind::UnknownMethod,
45786- format!("object has no method named {name}"),
45787- )),
45788- }
45789- }
45790- }
45791-
45792- impl minijinja::value::StructObject for MailingList {
45793- fn get_field(&self, name: &str) -> Option<Value> {
45794- match name {
45795- "pk" => Some(Value::from_serializable(&self.pk)),
45796- "name" => Some(Value::from_serializable(&self.name)),
45797- "id" => Some(Value::from_serializable(&self.id)),
45798- "address" => Some(Value::from_serializable(&self.address)),
45799- "description" if self.is_description_html_safe => {
45800- self.description.as_ref().map_or_else(
45801- || Some(Value::from_serializable(&self.description)),
45802- |d| Some(Value::from_safe_string(d.clone())),
45803- )
45804- }
45805- "description" => Some(Value::from_serializable(&self.description)),
45806- "topics" => Some(Value::from_serializable(&self.topics)),
45807- "archive_url" => Some(Value::from_serializable(&self.archive_url)),
45808- "is_description_html_safe" => {
45809- Some(Value::from_serializable(&self.is_description_html_safe))
45810- }
45811- _ => None,
45812- }
45813- }
45814-
45815- fn static_fields(&self) -> Option<&'static [&'static str]> {
45816- Some(
45817- &[
45818- "pk",
45819- "name",
45820- "id",
45821- "address",
45822- "description",
45823- "topics",
45824- "archive_url",
45825- "is_description_html_safe",
45826- ][..],
45827- )
45828- }
45829- }
45830-
45831- /// Return a vector of weeks, with each week being a vector of 7 days and
45832- /// corresponding sum of posts per day.
45833- pub fn calendarize(
45834- _state: &minijinja::State,
45835- args: Value,
45836- hists: Value,
45837- ) -> std::result::Result<Value, Error> {
45838- use chrono::Month;
45839-
45840- macro_rules! month {
45841- ($int:expr) => {{
45842- let int = $int;
45843- match int {
45844- 1 => Month::January.name(),
45845- 2 => Month::February.name(),
45846- 3 => Month::March.name(),
45847- 4 => Month::April.name(),
45848- 5 => Month::May.name(),
45849- 6 => Month::June.name(),
45850- 7 => Month::July.name(),
45851- 8 => Month::August.name(),
45852- 9 => Month::September.name(),
45853- 10 => Month::October.name(),
45854- 11 => Month::November.name(),
45855- 12 => Month::December.name(),
45856- _ => unreachable!(),
45857- }
45858- }};
45859- }
45860- let month = args.as_str().unwrap();
45861- let hist = hists
45862- .get_item(&Value::from(month))?
45863- .as_seq()
45864- .unwrap()
45865- .iter()
45866- .map(|v| usize::try_from(v).unwrap())
45867- .collect::<Vec<usize>>();
45868- let sum: usize = hists
45869- .get_item(&Value::from(month))?
45870- .as_seq()
45871- .unwrap()
45872- .iter()
45873- .map(|v| usize::try_from(v).unwrap())
45874- .sum();
45875- let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
45876- // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
45877- Ok(minijinja::context! {
45878- month_name => month!(date.month()),
45879- month => month,
45880- month_int => date.month() as usize,
45881- year => date.year(),
45882- weeks => cal::calendarize_with_offset(date, 1),
45883- hist => hist,
45884- sum,
45885- })
45886- }
45887-
45888- /// `pluralize` filter for [`minijinja`].
45889- ///
45890- /// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
45891- /// length `1`. By default, the plural suffix is 's' and the singular suffix is
45892- /// empty (''). You can specify a singular suffix as the first argument (or
45893- /// `None`, for the default). You can specify a plural suffix as the second
45894- /// argument (or `None`, for the default).
45895- ///
45896- /// See the examples for the correct usage.
45897- ///
45898- /// # Examples
45899- ///
45900- /// ```rust,no_run
45901- /// # use mailpot_web::pluralize;
45902- /// # use minijinja::Environment;
45903- ///
45904- /// let mut env = Environment::new();
45905- /// env.add_filter("pluralize", pluralize);
45906- /// for (num, s) in [
45907- /// (0, "You have 0 messages."),
45908- /// (1, "You have 1 message."),
45909- /// (10, "You have 10 messages."),
45910- /// ] {
45911- /// assert_eq!(
45912- /// &env.render_str(
45913- /// "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
45914- /// minijinja::context! {
45915- /// num_messages => num,
45916- /// }
45917- /// )
45918- /// .unwrap(),
45919- /// s
45920- /// );
45921- /// }
45922- ///
45923- /// for (num, s) in [
45924- /// (0, "You have 0 walruses."),
45925- /// (1, "You have 1 walrus."),
45926- /// (10, "You have 10 walruses."),
45927- /// ] {
45928- /// assert_eq!(
45929- /// &env.render_str(
45930- /// r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
45931- /// minijinja::context! {
45932- /// num_walruses => num,
45933- /// }
45934- /// )
45935- /// .unwrap(),
45936- /// s
45937- /// );
45938- /// }
45939- ///
45940- /// for (num, s) in [
45941- /// (0, "You have 0 cherries."),
45942- /// (1, "You have 1 cherry."),
45943- /// (10, "You have 10 cherries."),
45944- /// ] {
45945- /// assert_eq!(
45946- /// &env.render_str(
45947- /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
45948- /// minijinja::context! {
45949- /// num_cherries => num,
45950- /// }
45951- /// )
45952- /// .unwrap(),
45953- /// s
45954- /// );
45955- /// }
45956- ///
45957- /// assert_eq!(
45958- /// &env.render_str(
45959- /// r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
45960- /// minijinja::context! {
45961- /// num_cherries => vec![(); 5],
45962- /// }
45963- /// )
45964- /// .unwrap(),
45965- /// "You have 5 cherries."
45966- /// );
45967- ///
45968- /// assert_eq!(
45969- /// &env.render_str(
45970- /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
45971- /// minijinja::context! {
45972- /// num_cherries => "5",
45973- /// }
45974- /// )
45975- /// .unwrap(),
45976- /// "You have 5 cherries."
45977- /// );
45978- /// assert_eq!(
45979- /// &env.render_str(
45980- /// r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
45981- /// minijinja::context! {
45982- /// num_cherries => true,
45983- /// }
45984- /// )
45985- /// .unwrap()
45986- /// .to_string(),
45987- /// "You have 1 cherry.",
45988- /// );
45989- /// assert_eq!(
45990- /// &env.render_str(
45991- /// r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
45992- /// minijinja::context! {
45993- /// num_cherries => 0.5f32,
45994- /// }
45995- /// )
45996- /// .unwrap_err()
45997- /// .to_string(),
45998- /// "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
45999- /// length but of type number (in <string>:1)",
46000- /// );
46001- /// ```
46002- pub fn pluralize(
46003- v: Value,
46004- singular: Option<String>,
46005- plural: Option<String>,
46006- ) -> Result<Value, minijinja::Error> {
46007- macro_rules! int_try_from {
46008- ($ty:ty) => {
46009- <$ty>::try_from(v.clone()).ok().map(|v| v != 1)
46010- };
46011- ($fty:ty, $($ty:ty),*) => {
46012- int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
46013- }
46014- }
46015- let is_plural: bool = v
46016- .as_str()
46017- .and_then(|s| s.parse::<i128>().ok())
46018- .map(|l| l != 1)
46019- .or_else(|| v.len().map(|l| l != 1))
46020- .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
46021- .ok_or_else(|| {
46022- minijinja::Error::new(
46023- minijinja::ErrorKind::InvalidOperation,
46024- format!(
46025- "Pluralize argument is not an integer, or a sequence / object with a length \
46026- but of type {}",
46027- v.kind()
46028- ),
46029- )
46030- })?;
46031- Ok(match (is_plural, singular, plural) {
46032- (false, None, _) => "".into(),
46033- (false, Some(suffix), _) => suffix.into(),
46034- (true, _, None) => "s".into(),
46035- (true, _, Some(suffix)) => suffix.into(),
46036- })
46037- }
46038-
46039- /// `strip_carets` filter for [`minijinja`].
46040- ///
46041- /// Removes `[<>]` from message ids.
46042- pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
46043- Ok(Value::from(
46044- arg.as_str()
46045- .ok_or_else(|| {
46046- minijinja::Error::new(
46047- minijinja::ErrorKind::InvalidOperation,
46048- format!("argument to strip_carets() is of type {}", arg.kind()),
46049- )
46050- })?
46051- .strip_carets(),
46052- ))
46053- }
46054-
46055- /// `urlize` filter for [`minijinja`].
46056- ///
46057- /// Returns a safe string for use in `<a href=..` attributes.
46058- ///
46059- /// # Examples
46060- ///
46061- /// ```rust,no_run
46062- /// # use mailpot_web::urlize;
46063- /// # use minijinja::Environment;
46064- /// # use minijinja::value::Value;
46065- ///
46066- /// let mut env = Environment::new();
46067- /// env.add_function("urlize", urlize);
46068- /// env.add_global(
46069- /// "root_url_prefix",
46070- /// Value::from_safe_string("/lists/prefix/".to_string()),
46071- /// );
46072- /// assert_eq!(
46073- /// &env.render_str(
46074- /// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
46075- /// minijinja::context! {}
46076- /// )
46077- /// .unwrap(),
46078- /// "<a href=\"/lists/prefix/path/index.html\">link</a>",
46079- /// );
46080- /// ```
46081- pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
46082- let Some(prefix) = state.lookup("root_url_prefix") else {
46083- return Ok(arg);
46084- };
46085- Ok(Value::from_safe_string(format!("{prefix}{arg}")))
46086- }
46087-
46088- /// Make an html heading: `h1, h2, h3` etc.
46089- ///
46090- /// # Example
46091- /// ```rust,no_run
46092- /// use mailpot_web::minijinja_utils::heading;
46093- /// use minijinja::value::Value;
46094- ///
46095- /// assert_eq!(
46096- /// "<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>",
46097- /// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
46098- /// );
46099- /// assert_eq!(
46100- /// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
46101- /// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
46102- /// );
46103- /// assert_eq!(
46104- /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
46105- /// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
46106- /// );
46107- /// assert_eq!(
46108- /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
46109- /// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
46110- /// );
46111- /// assert_eq!(
46112- /// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
46113- /// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
46114- /// );
46115- /// ```
46116- pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
46117- use convert_case::{Case, Casing};
46118- macro_rules! test {
46119- () => {
46120- |n| *n > 0 && *n < 7
46121- };
46122- }
46123-
46124- macro_rules! int_try_from {
46125- ($ty:ty) => {
46126- <$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
46127- };
46128- ($fty:ty, $($ty:ty),*) => {
46129- int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
46130- }
46131- }
46132- let level: u8 = level
46133- .as_str()
46134- .and_then(|s| s.parse::<i128>().ok())
46135- .filter(test! {})
46136- .map(|n| n as u8)
46137- .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
46138- .ok_or_else(|| {
46139- if matches!(level.kind(), minijinja::value::ValueKind::Number) {
46140- minijinja::Error::new(
46141- minijinja::ErrorKind::InvalidOperation,
46142- "first heading() argument must be an unsigned integer less than 7 and positive",
46143- )
46144- } else {
46145- minijinja::Error::new(
46146- minijinja::ErrorKind::InvalidOperation,
46147- format!(
46148- "first heading() argument is not an integer < 7 but of type {}",
46149- level.kind()
46150- ),
46151- )
46152- }
46153- })?;
46154- let text = text.as_str().ok_or_else(|| {
46155- minijinja::Error::new(
46156- minijinja::ErrorKind::InvalidOperation,
46157- format!(
46158- "second heading() argument is not a string but of type {}",
46159- text.kind()
46160- ),
46161- )
46162- })?;
46163- if let Some(v) = id {
46164- let kebab = v.as_str().ok_or_else(|| {
46165- minijinja::Error::new(
46166- minijinja::ErrorKind::InvalidOperation,
46167- format!(
46168- "third heading() argument is not a string but of type {}",
46169- v.kind()
46170- ),
46171- )
46172- })?;
46173- Ok(Value::from_safe_string(format!(
46174- "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
46175- href=\"#{kebab}\"></a></h{level}>"
46176- )))
46177- } else {
46178- let kebab_v = text.to_case(Case::Kebab);
46179- let kebab =
46180- percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
46181- Ok(Value::from_safe_string(format!(
46182- "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
46183- href=\"#{kebab}\"></a></h{level}>"
46184- )))
46185- }
46186- }
46187-
46188- /// Make an array of topic strings into html badges.
46189- ///
46190- /// # Example
46191- /// ```rust
46192- /// use mailpot_web::minijinja_utils::topics;
46193- /// use minijinja::value::Value;
46194- ///
46195- /// let v: Value = topics(Value::from_serializable(&vec![
46196- /// "a".to_string(),
46197- /// "aab".to_string(),
46198- /// "aaab".to_string(),
46199- /// ]))
46200- /// .unwrap();
46201- /// assert_eq!(
46202- /// "<ul class=\"tags\"><li class=\"tag\" style=\"--red:110;--green:120;--blue:180;\"><span \
46203- /// class=\"tag-name\"><a href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
46204- /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
46205- /// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
46206- /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
46207- /// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
46208- /// &v.to_string()
46209- /// );
46210- /// ```
46211- pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
46212- topics.try_iter()?;
46213- let topics: Vec<String> = topics
46214- .try_iter()?
46215- .map(|v| v.to_string())
46216- .collect::<Vec<String>>();
46217- topics_common(&topics)
46218- }
46219-
46220- pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
46221- let mut ul = String::new();
46222- write!(&mut ul, r#"<ul class="tags">"#)?;
46223- for topic in topics {
46224- write!(
46225- &mut ul,
46226- r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
46227- )?;
46228- write!(&mut ul, "{}", TopicsPath)?;
46229- write!(&mut ul, r#"?query="#)?;
46230- write!(
46231- &mut ul,
46232- "{}",
46233- utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
46234- )?;
46235- write!(&mut ul, r#"">"#)?;
46236- write!(&mut ul, "{}", topic)?;
46237- write!(&mut ul, r#"</a></span></li>"#)?;
46238- }
46239- write!(&mut ul, r#"</ul>"#)?;
46240- Ok(Value::from_safe_string(ul))
46241- }
46242-
46243- #[cfg(test)]
46244- mod tests {
46245- use super::*;
46246-
46247- #[test]
46248- fn test_pluralize() {
46249- let mut env = Environment::new();
46250- env.add_filter("pluralize", pluralize);
46251- for (num, s) in [
46252- (0, "You have 0 messages."),
46253- (1, "You have 1 message."),
46254- (10, "You have 10 messages."),
46255- ] {
46256- assert_eq!(
46257- &env.render_str(
46258- "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
46259- minijinja::context! {
46260- num_messages => num,
46261- }
46262- )
46263- .unwrap(),
46264- s
46265- );
46266- }
46267-
46268- for (num, s) in [
46269- (0, "You have 0 walruses."),
46270- (1, "You have 1 walrus."),
46271- (10, "You have 10 walruses."),
46272- ] {
46273- assert_eq!(
46274- &env.render_str(
46275- r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
46276- minijinja::context! {
46277- num_walruses => num,
46278- }
46279- )
46280- .unwrap(),
46281- s
46282- );
46283- }
46284-
46285- for (num, s) in [
46286- (0, "You have 0 cherries."),
46287- (1, "You have 1 cherry."),
46288- (10, "You have 10 cherries."),
46289- ] {
46290- assert_eq!(
46291- &env.render_str(
46292- r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
46293- minijinja::context! {
46294- num_cherries => num,
46295- }
46296- )
46297- .unwrap(),
46298- s
46299- );
46300- }
46301-
46302- assert_eq!(
46303- &env.render_str(
46304- r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
46305- minijinja::context! {
46306- num_cherries => vec![(); 5],
46307- }
46308- )
46309- .unwrap(),
46310- "You have 5 cherries."
46311- );
46312-
46313- assert_eq!(
46314- &env.render_str(
46315- r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
46316- minijinja::context! {
46317- num_cherries => "5",
46318- }
46319- )
46320- .unwrap(),
46321- "You have 5 cherries."
46322- );
46323- assert_eq!(
46324- &env.render_str(
46325- r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
46326- minijinja::context! {
46327- num_cherries => true,
46328- }
46329- )
46330- .unwrap(),
46331- "You have 1 cherry.",
46332- );
46333- assert_eq!(
46334- &env.render_str(
46335- r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
46336- minijinja::context! {
46337- num_cherries => 0.5f32,
46338- }
46339- )
46340- .unwrap_err()
46341- .to_string(),
46342- "invalid operation: Pluralize argument is not an integer, or a sequence / object with \
46343- a length but of type number (in <string>:1)",
46344- );
46345- }
46346-
46347- #[test]
46348- fn test_urlize() {
46349- let mut env = Environment::new();
46350- env.add_function("urlize", urlize);
46351- env.add_global(
46352- "root_url_prefix",
46353- Value::from_safe_string("/lists/prefix/".to_string()),
46354- );
46355- assert_eq!(
46356- &env.render_str(
46357- "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
46358- minijinja::context! {}
46359- )
46360- .unwrap(),
46361- "<a href=\"/lists/prefix/path/index.html\">link</a>",
46362- );
46363- }
46364-
46365- #[test]
46366- fn test_heading() {
46367- assert_eq!(
46368- "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a \
46369- class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
46370- &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None)
46371- .unwrap()
46372- .to_string()
46373- );
46374- assert_eq!(
46375- "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" \
46376- href=\"#short\"></a></h2>",
46377- &heading(
46378- 2.into(),
46379- "bl bfa B AH bAsdb hadas d".into(),
46380- Some("short".into())
46381- )
46382- .unwrap()
46383- .to_string()
46384- );
46385- assert_eq!(
46386- r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
46387- &heading(
46388- 0.into(),
46389- "bl bfa B AH bAsdb hadas d".into(),
46390- Some("short".into())
46391- )
46392- .unwrap_err()
46393- .to_string()
46394- );
46395- assert_eq!(
46396- r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
46397- &heading(
46398- 8.into(),
46399- "bl bfa B AH bAsdb hadas d".into(),
46400- Some("short".into())
46401- )
46402- .unwrap_err()
46403- .to_string()
46404- );
46405- assert_eq!(
46406- r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
46407- &heading(
46408- Value::from(vec![Value::from(1)]),
46409- "bl bfa B AH bAsdb hadas d".into(),
46410- Some("short".into())
46411- )
46412- .unwrap_err()
46413- .to_string()
46414- );
46415- }
46416-
46417- #[test]
46418- fn test_strip_carets() {
46419- let mut env = Environment::new();
46420- env.add_filter("strip_carets", strip_carets);
46421- assert_eq!(
46422- &env.render_str(
46423- "{{ msg_id | strip_carets }}",
46424- minijinja::context! {
46425- msg_id => "<hello1@example.com>",
46426- }
46427- )
46428- .unwrap(),
46429- "hello1@example.com",
46430- );
46431- }
46432-
46433- #[test]
46434- fn test_calendarize() {
46435- use std::collections::HashMap;
46436-
46437- let mut env = Environment::new();
46438- env.add_function("calendarize", calendarize);
46439-
46440- let month = "2001-09";
46441- let mut hist = [0usize; 31];
46442- hist[15] = 5;
46443- hist[1] = 1;
46444- hist[0] = 512;
46445- hist[30] = 30;
46446- assert_eq!(
46447- &env.render_str(
46448- "{% set c=calendarize(month, hists) %}Month: {{ c.month }} Month Name: {{ \
46449- c.month_name }} Month Int: {{ c.month_int }} Year: {{ c.year }} Sum: {{ c.sum }} {% \
46450- for week in c.weeks %}{% for day in week %}{% set num = c.hist[day-1] %}({{ day }}, \
46451- {{ num }}){% endfor %}{% endfor %}",
46452- minijinja::context! {
46453- month,
46454- hists => vec![(month.to_string(), hist)].into_iter().collect::<HashMap<String, [usize;
46455- 31]>>(),
46456- }
46457- )
46458- .unwrap(),
46459- "Month: 2001-09 Month Name: September Month Int: 9 Year: 2001 Sum: 548 (0, 30)(0, 30)(0, \
46460- 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, \
46461- 0)(12, 0)(13, 0)(14, 0)(15, 0)(16, 5)(17, 0)(18, 0)(19, 0)(20, 0)(21, 0)(22, 0)(23, \
46462- 0)(24, 0)(25, 0)(26, 0)(27, 0)(28, 0)(29, 0)(30, 0)"
46463- );
46464- }
46465-
46466- #[test]
46467- fn test_list_html_safe() {
46468- let mut list = MailingList {
46469- pk: 0,
46470- name: String::new(),
46471- id: String::new(),
46472- address: String::new(),
46473- description: None,
46474- topics: vec![],
46475- archive_url: None,
46476- inner: DbVal(
46477- mailpot::models::MailingList {
46478- pk: 0,
46479- name: String::new(),
46480- id: String::new(),
46481- address: String::new(),
46482- description: None,
46483- topics: vec![],
46484- archive_url: None,
46485- },
46486- 0,
46487- ),
46488- is_description_html_safe: false,
46489- };
46490-
46491- let mut list_owners = vec![ListOwner {
46492- pk: 0,
46493- list: 0,
46494- address: "admin@example.com".to_string(),
46495- name: None,
46496- }];
46497- let administrators = vec!["admin@example.com".to_string()];
46498- list.set_safety(&list_owners, &administrators);
46499- assert!(list.is_description_html_safe);
46500- list.set_safety::<ListOwner>(&[], &[]);
46501- assert!(list.is_description_html_safe);
46502- list.is_description_html_safe = false;
46503- list_owners[0].address = "user@example.com".to_string();
46504- list.set_safety(&list_owners, &administrators);
46505- assert!(!list.is_description_html_safe);
46506- }
46507- }
46508 diff --git a/web/src/minijinja_utils/compressed.rs b/web/src/minijinja_utils/compressed.rs
46509deleted file mode 100644
46510index 8965d02..0000000
46511--- a/web/src/minijinja_utils/compressed.rs
46512+++ /dev/null
46513 @@ -1,20 +0,0 @@
46514- /*
46515- * This file is part of mailpot
46516- *
46517- * Copyright 2020 - Manos Pitsidianakis
46518- *
46519- * This program is free software: you can redistribute it and/or modify
46520- * it under the terms of the GNU Affero General Public License as
46521- * published by the Free Software Foundation, either version 3 of the
46522- * License, or (at your option) any later version.
46523- *
46524- * This program is distributed in the hope that it will be useful,
46525- * but WITHOUT ANY WARRANTY; without even the implied warranty of
46526- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
46527- * GNU Affero General Public License for more details.
46528- *
46529- * You should have received a copy of the GNU Affero General Public License
46530- * along with this program. If not, see <https://www.gnu.org/licenses/>.
46531- */
46532-
46533- pub const COMPRESSED: &[(&str, &[u8])] = include!("compressed.data");
46534 diff --git a/web/src/settings.rs b/web/src/settings.rs
46535deleted file mode 100644
46536index 13a6736..0000000
46537--- a/web/src/settings.rs
46538+++ /dev/null
46539 @@ -1,411 +0,0 @@
46540- /*
46541- * This file is part of mailpot
46542- *
46543- * Copyright 2020 - Manos Pitsidianakis
46544- *
46545- * This program is free software: you can redistribute it and/or modify
46546- * it under the terms of the GNU Affero General Public License as
46547- * published by the Free Software Foundation, either version 3 of the
46548- * License, or (at your option) any later version.
46549- *
46550- * This program is distributed in the hope that it will be useful,
46551- * but WITHOUT ANY WARRANTY; without even the implied warranty of
46552- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
46553- * GNU Affero General Public License for more details.
46554- *
46555- * You should have received a copy of the GNU Affero General Public License
46556- * along with this program. If not, see <https://www.gnu.org/licenses/>.
46557- */
46558-
46559- use mailpot::models::{
46560- changesets::{AccountChangeset, ListSubscriptionChangeset},
46561- ListSubscription,
46562- };
46563-
46564- use super::*;
46565-
46566- pub async fn settings(
46567- _: SettingsPath,
46568- mut session: WritableSession,
46569- Extension(user): Extension<User>,
46570- state: Arc<AppState>,
46571- ) -> Result<Html<String>, ResponseError> {
46572- let crumbs = vec![
46573- Crumb {
46574- label: "Home".into(),
46575- url: "/".into(),
46576- },
46577- Crumb {
46578- label: "Settings".into(),
46579- url: SettingsPath.to_crumb(),
46580- },
46581- ];
46582- let db = Connection::open_db(state.conf.clone())?;
46583- let acc = db
46584- .account_by_address(&user.address)
46585- .with_status(StatusCode::BAD_REQUEST)?
46586- .ok_or_else(|| {
46587- ResponseError::new("Account not found".to_string(), StatusCode::BAD_REQUEST)
46588- })?;
46589- let subscriptions = db
46590- .account_subscriptions(acc.pk())
46591- .with_status(StatusCode::BAD_REQUEST)?
46592- .into_iter()
46593- .filter_map(|s| match db.list(s.list) {
46594- Err(err) => Some(Err(err)),
46595- Ok(Some(list)) => Some(Ok((s, list))),
46596- Ok(None) => None,
46597- })
46598- .collect::<Result<
46599- Vec<(
46600- DbVal<mailpot::models::ListSubscription>,
46601- DbVal<mailpot::models::MailingList>,
46602- )>,
46603- mailpot::Error,
46604- >>()?;
46605-
46606- let context = minijinja::context! {
46607- page_title => "Account settings",
46608- user => user,
46609- subscriptions => subscriptions,
46610- current_user => user,
46611- messages => session.drain_messages(),
46612- crumbs => crumbs,
46613- };
46614- Ok(Html(
46615- TEMPLATES.get_template("settings.html")?.render(context)?,
46616- ))
46617- }
46618-
46619- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
46620- #[serde(tag = "type", rename_all = "kebab-case")]
46621- pub enum ChangeSetting {
46622- Subscribe { list_pk: IntPOST },
46623- Unsubscribe { list_pk: IntPOST },
46624- ChangePassword { new: String },
46625- ChangePublicKey { new: String },
46626- // RemovePassword,
46627- RemovePublicKey,
46628- ChangeName { new: String },
46629- }
46630-
46631- #[allow(non_snake_case)]
46632- pub async fn settings_POST(
46633- _: SettingsPath,
46634- mut session: WritableSession,
46635- Extension(user): Extension<User>,
46636- Form(payload): Form<ChangeSetting>,
46637- state: Arc<AppState>,
46638- ) -> Result<Redirect, ResponseError> {
46639- let db = Connection::open_db(state.conf.clone())?;
46640- let acc = db
46641- .account_by_address(&user.address)
46642- .with_status(StatusCode::BAD_REQUEST)?
46643- .ok_or_else(|| {
46644- ResponseError::new("Account not found".to_string(), StatusCode::BAD_REQUEST)
46645- })?;
46646-
46647- match payload {
46648- ChangeSetting::Subscribe {
46649- list_pk: IntPOST(list_pk),
46650- } => {
46651- let subscriptions = db
46652- .account_subscriptions(acc.pk())
46653- .with_status(StatusCode::BAD_REQUEST)?;
46654- if subscriptions.iter().any(|s| s.list == list_pk) {
46655- session.add_message(Message {
46656- message: "You are already subscribed to this list.".into(),
46657- level: Level::Info,
46658- })?;
46659- } else {
46660- db.add_subscription(
46661- list_pk,
46662- ListSubscription {
46663- pk: 0,
46664- list: list_pk,
46665- account: Some(acc.pk()),
46666- address: acc.address.clone(),
46667- name: acc.name.clone(),
46668- digest: false,
46669- enabled: true,
46670- verified: true,
46671- hide_address: false,
46672- receive_duplicates: false,
46673- receive_own_posts: false,
46674- receive_confirmation: false,
46675- },
46676- )?;
46677- session.add_message(Message {
46678- message: "You have subscribed to this list.".into(),
46679- level: Level::Success,
46680- })?;
46681- }
46682- }
46683- ChangeSetting::Unsubscribe {
46684- list_pk: IntPOST(list_pk),
46685- } => {
46686- let subscriptions = db
46687- .account_subscriptions(acc.pk())
46688- .with_status(StatusCode::BAD_REQUEST)?;
46689- if !subscriptions.iter().any(|s| s.list == list_pk) {
46690- session.add_message(Message {
46691- message: "You are already not subscribed to this list.".into(),
46692- level: Level::Info,
46693- })?;
46694- } else {
46695- let db = db.trusted();
46696- db.remove_subscription(list_pk, &acc.address)?;
46697- session.add_message(Message {
46698- message: "You have unsubscribed from this list.".into(),
46699- level: Level::Success,
46700- })?;
46701- }
46702- }
46703- ChangeSetting::ChangePassword { new } => {
46704- db.update_account(AccountChangeset {
46705- address: acc.address.clone(),
46706- name: None,
46707- public_key: None,
46708- password: Some(new.clone()),
46709- enabled: None,
46710- })
46711- .with_status(StatusCode::BAD_REQUEST)?;
46712- session.add_message(Message {
46713- message: "You have successfully updated your SSH public key.".into(),
46714- level: Level::Success,
46715- })?;
46716- let mut user = user.clone();
46717- user.password = new;
46718- state.insert_user(acc.pk(), user).await;
46719- }
46720- ChangeSetting::ChangePublicKey { new } => {
46721- db.update_account(AccountChangeset {
46722- address: acc.address.clone(),
46723- name: None,
46724- public_key: Some(Some(new.clone())),
46725- password: None,
46726- enabled: None,
46727- })
46728- .with_status(StatusCode::BAD_REQUEST)?;
46729- session.add_message(Message {
46730- message: "You have successfully updated your PGP public key.".into(),
46731- level: Level::Success,
46732- })?;
46733- let mut user = user.clone();
46734- user.public_key = Some(new);
46735- state.insert_user(acc.pk(), user).await;
46736- }
46737- ChangeSetting::RemovePublicKey => {
46738- db.update_account(AccountChangeset {
46739- address: acc.address.clone(),
46740- name: None,
46741- public_key: Some(None),
46742- password: None,
46743- enabled: None,
46744- })
46745- .with_status(StatusCode::BAD_REQUEST)?;
46746- session.add_message(Message {
46747- message: "You have successfully removed your PGP public key.".into(),
46748- level: Level::Success,
46749- })?;
46750- let mut user = user.clone();
46751- user.public_key = None;
46752- state.insert_user(acc.pk(), user).await;
46753- }
46754- ChangeSetting::ChangeName { new } => {
46755- let new = if new.trim().is_empty() {
46756- None
46757- } else {
46758- Some(new)
46759- };
46760- db.update_account(AccountChangeset {
46761- address: acc.address.clone(),
46762- name: Some(new.clone()),
46763- public_key: None,
46764- password: None,
46765- enabled: None,
46766- })
46767- .with_status(StatusCode::BAD_REQUEST)?;
46768- session.add_message(Message {
46769- message: "You have successfully updated your name.".into(),
46770- level: Level::Success,
46771- })?;
46772- let mut user = user.clone();
46773- user.name = new.clone();
46774- state.insert_user(acc.pk(), user).await;
46775- }
46776- }
46777-
46778- Ok(Redirect::to(&format!(
46779- "{}{}",
46780- &state.root_url_prefix,
46781- SettingsPath.to_uri()
46782- )))
46783- }
46784-
46785- pub async fn user_list_subscription(
46786- ListSettingsPath(id): ListSettingsPath,
46787- mut session: WritableSession,
46788- Extension(user): Extension<User>,
46789- State(state): State<Arc<AppState>>,
46790- ) -> Result<Html<String>, ResponseError> {
46791- let db = Connection::open_db(state.conf.clone())?;
46792- let Some(list) = (match id {
46793- ListPathIdentifier::Pk(id) => db.list(id)?,
46794- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
46795- }) else {
46796- return Err(ResponseError::new(
46797- "List not found".to_string(),
46798- StatusCode::NOT_FOUND,
46799- ));
46800- };
46801- let acc = match db.account_by_address(&user.address)? {
46802- Some(v) => v,
46803- None => {
46804- return Err(ResponseError::new(
46805- "Account not found".to_string(),
46806- StatusCode::BAD_REQUEST,
46807- ))
46808- }
46809- };
46810- let mut subscriptions = db
46811- .account_subscriptions(acc.pk())
46812- .with_status(StatusCode::BAD_REQUEST)?;
46813- subscriptions.retain(|s| s.list == list.pk());
46814- let subscription = db
46815- .list_subscription(
46816- list.pk(),
46817- subscriptions
46818- .first()
46819- .ok_or_else(|| {
46820- ResponseError::new(
46821- "Subscription not found".to_string(),
46822- StatusCode::BAD_REQUEST,
46823- )
46824- })?
46825- .pk(),
46826- )
46827- .with_status(StatusCode::BAD_REQUEST)?;
46828-
46829- let crumbs = vec![
46830- Crumb {
46831- label: "Home".into(),
46832- url: "/".into(),
46833- },
46834- Crumb {
46835- label: "Settings".into(),
46836- url: SettingsPath.to_crumb(),
46837- },
46838- Crumb {
46839- label: "List Subscription".into(),
46840- url: ListSettingsPath(list.pk().into()).to_crumb(),
46841- },
46842- ];
46843-
46844- let list_owners = db.list_owners(list.pk)?;
46845- let mut list = crate::minijinja_utils::MailingList::from(list);
46846- list.set_safety(list_owners.as_slice(), &state.conf.administrators);
46847- let context = minijinja::context! {
46848- page_title => "Subscription settings",
46849- user => user,
46850- list => list,
46851- subscription => subscription,
46852- current_user => user,
46853- messages => session.drain_messages(),
46854- crumbs => crumbs,
46855- };
46856- Ok(Html(
46857- TEMPLATES
46858- .get_template("settings_subscription.html")?
46859- .render(context)?,
46860- ))
46861- }
46862-
46863- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
46864- pub struct SubscriptionFormPayload {
46865- #[serde(default)]
46866- pub digest: bool,
46867- #[serde(default)]
46868- pub hide_address: bool,
46869- #[serde(default)]
46870- pub receive_duplicates: bool,
46871- #[serde(default)]
46872- pub receive_own_posts: bool,
46873- #[serde(default)]
46874- pub receive_confirmation: bool,
46875- }
46876-
46877- #[allow(non_snake_case)]
46878- pub async fn user_list_subscription_POST(
46879- ListSettingsPath(id): ListSettingsPath,
46880- mut session: WritableSession,
46881- Extension(user): Extension<User>,
46882- Form(payload): Form<SubscriptionFormPayload>,
46883- state: Arc<AppState>,
46884- ) -> Result<Redirect, ResponseError> {
46885- let db = Connection::open_db(state.conf.clone())?;
46886-
46887- let Some(list) = (match id {
46888- ListPathIdentifier::Pk(id) => db.list(id)?,
46889- ListPathIdentifier::Id(id) => db.list_by_id(id)?,
46890- }) else {
46891- return Err(ResponseError::new(
46892- "List not found".to_string(),
46893- StatusCode::NOT_FOUND,
46894- ));
46895- };
46896-
46897- let acc = match db.account_by_address(&user.address)? {
46898- Some(v) => v,
46899- None => {
46900- return Err(ResponseError::new(
46901- "Account with this address was not found".to_string(),
46902- StatusCode::BAD_REQUEST,
46903- ));
46904- }
46905- };
46906- let mut subscriptions = db
46907- .account_subscriptions(acc.pk())
46908- .with_status(StatusCode::BAD_REQUEST)?;
46909-
46910- subscriptions.retain(|s| s.list == list.pk());
46911- let mut s = db
46912- .list_subscription(list.pk(), subscriptions[0].pk())
46913- .with_status(StatusCode::BAD_REQUEST)?;
46914-
46915- let SubscriptionFormPayload {
46916- digest,
46917- hide_address,
46918- receive_duplicates,
46919- receive_own_posts,
46920- receive_confirmation,
46921- } = payload;
46922-
46923- let cset = ListSubscriptionChangeset {
46924- list: s.list,
46925- address: std::mem::take(&mut s.address),
46926- account: None,
46927- name: None,
46928- digest: Some(digest),
46929- hide_address: Some(hide_address),
46930- receive_duplicates: Some(receive_duplicates),
46931- receive_own_posts: Some(receive_own_posts),
46932- receive_confirmation: Some(receive_confirmation),
46933- enabled: None,
46934- verified: None,
46935- };
46936-
46937- db.update_subscription(cset)
46938- .with_status(StatusCode::BAD_REQUEST)?;
46939-
46940- session.add_message(Message {
46941- message: "Settings saved successfully.".into(),
46942- level: Level::Success,
46943- })?;
46944-
46945- Ok(Redirect::to(&format!(
46946- "{}{}",
46947- &state.root_url_prefix,
46948- ListSettingsPath(list.id.clone().into()).to_uri()
46949- )))
46950- }
46951 diff --git a/web/src/templates/auth.html b/web/src/templates/auth.html
46952deleted file mode 100644
46953index 570c38e..0000000
46954--- a/web/src/templates/auth.html
46955+++ /dev/null
46956 @@ -1,15 +0,0 @@
46957- {% include "header.html" %}
46958- <div class="body body-grid">
46959- <p aria-label="instructions">Sign <mark class="ssh-challenge-token" title="challenge token">{{ ssh_challenge }}</mark> with your previously configured key within <time title="{{ timeout_left }} minutes left" datetime="{{ timeout_left }}">{{ timeout_left }} minutes</time>. Example:</p>
46960- <pre class="command-line-example" title="example terminal command for UNIX shells that signs the challenge token with a public SSH key" >printf <ruby>'<mark>{{ ssh_challenge }}</mark>'<rp>(</rp><rt>signin challenge</rt><rp>)</rp></ruby> | ssh-keygen -Y sign -f <ruby>~/.ssh/id_rsa <rp>(</rp><rt>your account's key</rt><rp>)</rp></ruby> -n <ruby>{{ namespace }}<rp>(</rp><rt>namespace</rt><rp>)</rp></ruby></pre>
46961- <form method="post" class="login-form login-ssh" aria-label="login form">
46962- <label for="id_address" id="id_address_label">Email address:</label>
46963- <input type="text" name="address" required="" id="id_address" aria-labelledby="id_address_label">
46964- <label for="id_password">SSH signature:</label>
46965- <textarea class="key-or-sig-input" name="password" cols="15" rows="5" placeholder="-----BEGIN SSH SIGNATURE-----&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;mechangemechangemechangemechangemechangemechangemechangemechangemechan&#10;gemechangemechangemechangemechangemechangemechangemechangemechangemech&#10;angemechangemechangemechangemechangemechangemechangemechangemechangeme&#10;changemechangemechangemechangemechangemechangemechangemechangemechange&#10;chang=&#10;-----END SSH SIGNATURE-----&#10;" required="" id="id_password"></textarea>
46966- <input type="submit" value="login">
46967- <input type="hidden" name="next" value="">
46968- <!--<input formaction="" formnovalidate="true" type="submit" name="refresh" value="refresh token"-->
46969- </form>
46970- </div>
46971- {% include "footer.html" %}
46972 diff --git a/web/src/templates/calendar.html b/web/src/templates/calendar.html
46973deleted file mode 100644
46974index 8eccf8f..0000000
46975--- a/web/src/templates/calendar.html
46976+++ /dev/null
46977 @@ -1,43 +0,0 @@
46978- {% macro cal(date, hists) %}
46979- {% set c=calendarize(date, hists) %}
46980- {% if c.sum > 0 %}
46981- <table>
46982- <caption align="top">
46983- <!--<a href="{{ root_url_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
46984- <a href="#" style="color: GrayText;">
46985- {{ c.month_name }} {{ c.year }}
46986- </a>
46987- </caption>
46988- <thead>
46989- <tr>
46990- <th>M</th>
46991- <th>Tu</th>
46992- <th>W</th>
46993- <th>Th</th>
46994- <th>F</th>
46995- <th>Sa</th>
46996- <th>Su</th>
46997- </tr>
46998- </thead>
46999- <tbody>
47000- {% for week in c.weeks %}
47001- <tr>
47002- {% for day in week %}
47003- {% if day == 0 %}
47004- <td></td>
47005- {% else %}
47006- {% set num = c.hist[day-1] %}
47007- {% if num > 0 %}
47008- <td><ruby>{{ day }}<rt>({{ num }})</rt></ruby></td>
47009- {% else %}
47010- <td class="empty">{{ day }}</td>
47011- {% endif %}
47012- {% endif %}
47013- {% endfor %}
47014- </tr>
47015- {% endfor %}
47016- </tbody>
47017- </table>
47018- {% endif %}
47019- {% endmacro %}
47020- {% set alias = cal %}
47021 diff --git a/web/src/templates/css.html b/web/src/templates/css.html
47022deleted file mode 100644
47023index f644210..0000000
47024--- a/web/src/templates/css.html
47025+++ /dev/null
47026 @@ -1,1092 +0,0 @@
47027- <style>@charset "UTF-8";
47028- /* Use a more intuitive box-sizing model */
47029- *, *::before, *::after {
47030- box-sizing: border-box;
47031- }
47032-
47033- /* Remove all margins & padding */
47034- * {
47035- margin: 0;
47036- padding: 0;
47037- word-wrap: break-word;
47038- }
47039-
47040- /* Only show focus outline when the user is tabbing (not when clicking) */
47041- *:focus {
47042- outline: none;
47043- }
47044-
47045- *:focus-visible {
47046- outline: 1px solid blue;
47047- }
47048-
47049- /* Prevent mobile browsers increasing font-size */
47050- html {
47051- -moz-text-size-adjust: none;
47052- -webkit-text-size-adjust: none;
47053- text-size-adjust: none;
47054- font-family:-apple-system,BlinkMacSystemFont,Arial,sans-serif;
47055- line-height:1.15;
47056- -webkit-text-size-adjust:100%;
47057- overflow-y:scroll;
47058- }
47059-
47060- /* Allow percentage-based heights */
47061- /* Setting width: 100% isn't required because it is a default for block-level elements (html & body are block level) */
47062- html, body {
47063- height: 100%;
47064- }
47065-
47066- body {
47067- /* Prevent the rubber band effect when the user scrolls to the top or bottom of the page (WebKit only) */
47068- overscroll-behavior: none;
47069-
47070- /* Prevent the browser from synthesizing missing typefaces */
47071- font-synthesis: none;
47072-
47073- margin:0;
47074- font-feature-settings:"onum" 1;
47075- text-rendering:optimizeLegibility;
47076- -webkit-font-smoothing:antialiased;
47077- -moz-osx-font-smoothing:grayscale;
47078- font-family:var(--sans-serif-system-stack);
47079- font-size:100%;
47080- }
47081-
47082- /* Remove unintuitive behaviour such as gaps around media elements. */
47083- img, picture, video, canvas, svg, iframe {
47084- display: block;
47085- }
47086-
47087- /* Avoid text overflow */
47088- h1, h2, h3, h4, h5, h6, p, strong {
47089- overflow-wrap: break-word;
47090- }
47091-
47092- p {
47093- line-height: 1.4;
47094- }
47095-
47096- h1,
47097- h2,
47098- h3,
47099- h4,
47100- h5,
47101- h6 {
47102- position: relative;
47103- }
47104- h1 > a.self-link,
47105- h2 > a.self-link,
47106- h3 > a.self-link,
47107- h4 > a.self-link,
47108- h5 > a.self-link,
47109- h6 > a.self-link {
47110- font-size: 83%;
47111- }
47112-
47113- a.self-link::before {
47114- content: "§";
47115- /* increase surface area for clicks */
47116- padding: 1rem;
47117- margin: -1rem;
47118- }
47119-
47120- a.self-link {
47121- --width: 22px;
47122- position: absolute;
47123- top: 0px;
47124- left: calc(var(--width) - 3.5rem);
47125- width: calc(-1 * var(--width) + 3.5rem);
47126- height: 2em;
47127- text-align: center;
47128- border: medium none;
47129- transition: opacity 0.2s ease 0s;
47130- opacity: 0.5;
47131- }
47132-
47133- a {
47134- text-decoration: none;
47135- }
47136-
47137- a[href]:focus, a[href]:hover {
47138- text-decoration-thickness: 2px;
47139- text-decoration-skip-ink: none;
47140- }
47141-
47142- a[href] {
47143- text-decoration: underline;
47144- color: #034575;
47145- color: var(--a-normal-text);
47146- text-decoration-color: #707070;
47147- text-decoration-color: var(--accent-secondary);
47148- text-decoration-skip-ink: none;
47149- }
47150-
47151- ul, ol {
47152- list-style: none;
47153- }
47154-
47155- code {
47156- font-family: var(--monospace-system-stack);
47157- overflow-wrap: anywhere;
47158- }
47159-
47160- pre {
47161- font-family: var(--monospace-system-stack);
47162- }
47163-
47164- input {
47165- border: none;
47166- }
47167-
47168- input, button, textarea, select {
47169- font: inherit;
47170- }
47171-
47172- /* Create a root stacking context (only when using frameworks like Next.js) */
47173- #__next {
47174- isolation: isolate;
47175- }
47176-
47177- :root {
47178- --emoji-system-stack: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
47179- --monospace-system-stack: /* apple */ ui-monospace, SFMono-Regular, Menlo, Monaco,
47180- /* windows */ "Cascadia Mono", "Segoe UI Mono", Consolas,
47181- /* free unixes */ "DejaVu Sans Mono", "Liberation Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace, var(--emoji-system-stack);
47182- --sans-serif-system-stack:-apple-system,BlinkMacSystemFont,Roboto,Roboto Slab,Droid Serif,Segoe UI,system-ui,Arial,sans-serif, var(--emoji-system-stack);
47183- --grotesque-system-stack: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif, var(--emoji-system-stack);
47184- --text-primary: CanvasText;
47185- --text-faded: GrayText;
47186- --horizontal-rule: #88929d;
47187- --code-foreground: #124;
47188- --code-background: #8fbcbb;
47189- --a-visited-text: var(--a-normal-text);
47190- --tag-border-color: black;
47191- }
47192-
47193- @media (prefers-color-scheme: light) {
47194- :root {
47195- --text-secondary: #4e4e4e;
47196- --text-inactive: #9e9e9ea6;
47197- --text-link: #0069c2;
47198- --text-invert: #fff;
47199- --background-primary: #fff;
47200- --background-secondary: #ebebeb;
47201- --background-tertiary: #fff;
47202- --background-toc-active: #ebeaea;
47203- --background-mark-yellow: #c7b70066;
47204- --background-mark-green: #00d06166;
47205- --background-information: #0085f21a;
47206- --background-warning: #ff2a511a;
47207- --background-critical: #d300381a;
47208- --background-success: #0079361a;
47209- --border-primary: #cdcdcd;
47210- --border-secondary: #cdcdcd;
47211- --button-primary-default: #1b1b1b;
47212- --button-primary-hover: #696969;
47213- --button-primary-active: #9e9e9e;
47214- --button-primary-inactive: #1b1b1b;
47215- --button-secondary-default: #fff;
47216- --button-secondary-hover: #cdcdcd;
47217- --button-secondary-active: #cdcdcd;
47218- --button-secondary-inactive: #f9f9fb;
47219- --button-secondary-border-focus: #0085f2;
47220- --button-secondary-border-red: #ff97a0;
47221- --button-secondary-border-red-focus: #ffd9dc;
47222- --icon-primary: #696969;
47223- --icon-secondary: #b3b3b3;
47224- --icon-information: #0085f2;
47225- --icon-warning: #ff2a51;
47226- --icon-critical: #d30038;
47227- --icon-success: #007936;
47228- --accent-primary: #0085f2;
47229- --accent-primary-engage: #0085f21a;
47230- --accent-secondary: #0085f2;
47231- --accent-tertiary: #0085f21a;
47232- --shadow-01: 0 1px 2px rgba(43,42,51,.05);
47233- --shadow-02: 0 1px 6px rgba(43,42,51,.1);
47234- --focus-01: 0 0 0 3px rgba(0,144,237,.4);
47235- --field-focus-border: #0085f2;
47236- --code-token-tag: #0069c2;
47237- --code-token-punctuation: #858585;
47238- --code-token-attribute-name: #d30038;
47239- --code-token-attribute-value: #007936;
47240- --code-token-comment: #858585;
47241- --code-token-default: #1b1b1b;
47242- --code-token-selector: #872bff;
47243- --code-background-inline: #f2f1f1;
47244- --code-background-block: #f2f1f1;
47245- --notecard-link-color: #343434;
47246- --scrollbar-bg: transparent;
47247- --scrollbar-color: #00000040;
47248- --category-color: #0085f2;
47249- --category-color-background: #0085f210;
47250- --code-color: #5e9eff;
47251- --mark-color: #dce2f2;
47252- --blend-color: #fff80;
47253- --text-primary-red: #d30038;
47254- --text-primary-green: #007936;
47255- --text-primary-blue: #0069c2;
47256- --text-primary-yellow: #746a00;
47257- --form-invalid-color: #d30038;
47258- --form-invalid-focus-color: #ff2a51;
47259- --form-invalid-focus-effect-color: #ff2a5133;
47260-
47261- --a-normal-text: #034575;
47262- --a-normal-underline: #bbb;
47263- --a-visited-underline: #707070;
47264- --a-hover-bg: #bfbfbf40;
47265- --a-active-text: #c00;
47266- --a-active-underline: #c00;
47267- --tag-border-color: #0000005e;
47268- color-scheme: light;
47269- }
47270- }
47271-
47272- @media (prefers-color-scheme: dark) {
47273- :root {
47274- --text-secondary: #cdcdcd;
47275- --text-inactive: #cdcdcda6;
47276- --text-link: #8cb4ff;
47277- --text-invert: #1b1b1b;
47278- --background-primary: #1b1b1b;
47279- --background-secondary: #343434;
47280- --background-tertiary: #4e4e4e;
47281- --background-toc-active: #343434;
47282- --background-mark-yellow: #c7b70066;
47283- --background-mark-green: #00d06166;
47284- --background-information: #0085f21a;
47285- --background-warning: #ff2a511a;
47286- --background-critical: #d300381a;
47287- --background-success: #0079361a;
47288- --border-primary: #858585;
47289- --border-secondary: #696969;
47290- --button-primary-default: #fff;
47291- --button-primary-hover: #cdcdcd;
47292- --button-primary-active: #9e9e9e;
47293- --button-primary-inactive: #fff;
47294- --button-secondary-default: #4e4e4e;
47295- --button-secondary-hover: #858585;
47296- --button-secondary-active: #9e9e9e;
47297- --button-secondary-inactive: #4e4e4e;
47298- --button-secondary-border-focus: #0085f2;
47299- --button-secondary-border-red: #ff97a0;
47300- --button-secondary-border-red-focus: #ffd9dc;
47301- --icon-primary: #fff;
47302- --icon-secondary: #b3b3b3;
47303- --icon-information: #5e9eff;
47304- --icon-warning: #afa100;
47305- --icon-critical: #ff707f;
47306- --icon-success: #00b755;
47307- --accent-primary: #5e9eff;
47308- --accent-primary-engage: #5e9eff1a;
47309- --accent-secondary: #5e9eff;
47310- --accent-tertiary: #0085f21a;
47311- --shadow-01: 0 1px 2px rgba(251,251,254,.2);
47312- --shadow-02: 0 1px 6px rgba(251,251,254,.2);
47313- --focus-01: 0 0 0 3px rgba(251,251,254,.5);
47314- --field-focus-border: #fff;
47315- --notecard-link-color: #e2e2e2;
47316- --scrollbar-bg: transparent;
47317- --scrollbar-color: #ffffff40;
47318- --category-color: #8cb4ff;
47319- --category-color-background: #8cb4ff70;
47320- --code-color: #c1cff1;
47321- --mark-color: #004d92;
47322- --blend-color: #00080;
47323- --text-primary-red: #ff97a0;
47324- --text-primary-green: #00d061;
47325- --text-primary-blue: #8cb4ff;
47326- --text-primary-yellow: #c7b700;
47327- --collections-link: #ff97a0;
47328- --collections-header: #40000a;
47329- --collections-mandala: #9e0027;
47330- --collections-icon: #d30038;
47331- --updates-link: #8cb4ff;
47332- --updates-header: #000;
47333- --updates-mandala: #c1cff1;
47334- --updates-icon: #8cb4ff;
47335- --form-limit-color: #9e9e9e;
47336- --form-limit-color-emphasis: #b3b3b3;
47337- --form-invalid-color: #ff97a0;
47338- --form-invalid-focus-color: #ff707f;
47339- --form-invalid-focus-effect-color: #ff707f33;
47340-
47341- --a-normal-text: #4db4ff;
47342- --a-normal-underline: #8b8b8b;
47343- --a-visited-underline: #707070;
47344- --a-hover-bg: #bfbfbf40;
47345- --a-active-text: #c00;
47346- --a-active-underline: #c00;
47347- --tag-border-color: #000;
47348-
47349- color-scheme: dark;
47350- }
47351- }
47352-
47353-
47354-
47355- body>main.layout {
47356- width: 100%;
47357- height: 100%;
47358- overflow-wrap: anywhere;
47359-
47360- display: grid;
47361- grid:
47362- "header header header" auto
47363- "leftside body rightside" 1fr
47364- "footer footer footer" auto
47365- / auto 1fr auto;
47366- gap: 8px;
47367- }
47368-
47369- main.layout>.header { grid-area: header; }
47370- main.layout>.leftside { grid-area: leftside; }
47371- main.layout>div.body {
47372- grid-area: body;
47373- width: 90vw;
47374- justify-self: center;
47375- align-self: start;
47376- }
47377- main.layout>.rightside { grid-area: rightside; }
47378- main.layout>footer {
47379- font-family: var(--grotesque-system-stack);
47380- grid-area: footer;
47381- border-top: 2px inset;
47382- margin-block-start: 1rem;
47383- border-color: var(--text-link);
47384- background-color: var(--text-primary-blue);
47385- color: var(--text-invert);
47386- }
47387-
47388- main.layout>footer a[href] {
47389- box-shadow: 2px 2px 2px black;
47390- background: Canvas;
47391- border: .3rem solid Canvas;
47392- border-radius: 3px;
47393- font-weight: bold;
47394- font-family: var(--monospace-system-stack);
47395- font-size: small;
47396- }
47397-
47398- main.layout>footer>* {
47399- margin-block-start: 1rem;
47400- margin-inline-start: 1rem;
47401- margin-block-end: 1rem;
47402- }
47403-
47404- main.layout>div.header>h1 {
47405- margin: 1rem;
47406- font-family: var(--grotesque-system-stack);
47407- font-size: xx-large;
47408- }
47409-
47410- main.layout>div.header>p.site-subtitle {
47411- margin: 1rem;
47412- margin-top: 0px;
47413- font-family: var(--grotesque-system-stack);
47414- font-size: large;
47415- }
47416-
47417- main.layout>div.header>div.page-header {
47418- width: 90vw;
47419- margin: 0px auto;
47420- }
47421-
47422- main.layout>div.header>div.page-header>nav:first-child {
47423- margin-top: 1rem;
47424- }
47425-
47426- main.layout>div.body *:is(h2,h3,h4,h5,h6) {
47427- padding-bottom: .3em;
47428- border-bottom: 1px solid var(--horizontal-rule);
47429- }
47430-
47431- nav.main-nav {
47432- padding: 0rem 1rem;
47433- border: 1px solid var(--border-secondary);
47434- border-left: none;
47435- border-right: none;
47436- border-radius: 2px;
47437- padding: 10px 14px 10px 10px;
47438- margin-bottom: 10px;
47439- }
47440-
47441- nav.main-nav>ul {
47442- display: flex;
47443- flex-wrap: wrap;
47444- gap: 1rem;
47445- }
47446- nav.main-nav>ul>li>a {
47447- /* fallback if clamp() isn't supported */
47448- padding: 1rem;
47449- padding: 1rem clamp(0.6svw,1rem,0.5vmin);
47450- }
47451- nav.main-nav > ul > li > a:hover {
47452- outline: 0.1rem solid;
47453- outline-offset: -0.5rem;
47454- }
47455- nav.main-nav >ul .push {
47456- margin-left: auto;
47457- }
47458-
47459- main.layout>div.header h2.page-title {
47460- margin: 1rem 0px;
47461- font-family: var(--grotesque-system-stack);
47462- }
47463-
47464- nav.breadcrumbs {
47465- padding: 10px 14px 10px 0px;
47466- }
47467-
47468- nav.breadcrumbs ol {
47469- list-style-type: none;
47470- padding-left: 0;
47471- font-size: small;
47472- }
47473-
47474- /* If only the root crumb is visible, hide it to avoid unnecessary visual clutter */
47475- li.crumb:only-child>span[aria-current="page"] {
47476- --secs: 150ms;
47477- transition: all var(--secs) linear;
47478- color: transparent;
47479- }
47480-
47481- li.crumb:only-child>span[aria-current="page"]:hover {
47482- transition: all var(--secs) linear;
47483- color: revert;
47484- }
47485-
47486- .crumb, .crumb>a {
47487- display: inline;
47488- }
47489-
47490- .crumb a::after {
47491- display: inline-block;
47492- color: var(--text-primary);
47493- content: '>';
47494- content: '>' / '';
47495- font-size: 80%;
47496- font-weight: bold;
47497- padding: 0 3px;
47498- }
47499-
47500- .crumb span[aria-current="page"] {
47501- color: var(--text-faded);
47502- padding: 0.4rem;
47503- margin-left: -0.4rem;
47504- display: inline;
47505- }
47506-
47507- ul.messagelist {
47508- list-style-type: none;
47509- margin: 0;
47510- padding: 0;
47511- background: var(--background-secondary);
47512- }
47513-
47514- ul.messagelist:not(:empty) {
47515- margin-block-end: 0.5rem;
47516- }
47517-
47518- ul.messagelist>li {
47519- padding: 1rem 0.7rem;
47520- --message-background: var(--icon-secondary);
47521- background: var(--message-background);
47522- border: 1px outset var(--message-background);
47523- border-radius: 2px;
47524- font-weight: 400;
47525- margin-block-end: 1.0rem;
47526- color: #0d0b0b;
47527- }
47528-
47529- ul.messagelist>li>span.label {
47530- text-transform: capitalize;
47531- font-weight: bolder;
47532- }
47533-
47534- ul.messagelist>li.error {
47535- --message-background: var(--icon-critical);
47536- }
47537-
47538- ul.messagelist>li.success {
47539- --message-background: var(--icon-success);
47540- }
47541-
47542- ul.messagelist>li.warning {
47543- --message-background: var(--icon-warning);
47544- }
47545-
47546- ul.messagelist>li.info {
47547- --message-background: var(--icon-information);
47548- }
47549-
47550- div.body>section {
47551- display: flex;
47552- flex-direction: column;
47553- gap: 1rem;
47554- }
47555-
47556- div.body>section+section{
47557- margin-top: 1rem;
47558- }
47559-
47560- div.calendar rt {
47561- white-space: nowrap;
47562- font-size: 50%;
47563- -moz-min-font-size-ratio: 50%;
47564- line-height: 1;
47565- }
47566- @supports not (display: ruby-text) {
47567- /* Chrome seems to display it at regular size, so scale it down */
47568- div.calendar rt {
47569- scale: 50%;
47570- font-size: 100%;
47571- }
47572- }
47573-
47574- div.calendar rt {
47575- display: ruby-text;
47576- }
47577-
47578- div.calendar th {
47579- padding: 0.5rem;
47580- opacity: 0.7;
47581- text-align: center;
47582- }
47583-
47584- div.calendar tr {
47585- text-align: right;
47586- }
47587-
47588- div.calendar tr,
47589- div.calendar th {
47590- font-variant-numeric: tabular-nums;
47591- font-family: var(--monospace-system-stack);
47592- }
47593-
47594- div.calendar table {
47595- display: inline-table;
47596- border-collapse: collapse;
47597- }
47598-
47599- div.calendar td {
47600- padding: 0.1rem 0.4rem;
47601- font-size: 80%;
47602- width: 2.3rem;
47603- height: 2.3rem;
47604- text-align: center;
47605- }
47606-
47607- div.calendar td.empty {
47608- color: var(--text-faded);
47609- }
47610-
47611- div.calendar td:not(.empty) {
47612- font-weight: bold;
47613- }
47614-
47615- div.calendar td:not(:empty) {
47616- border: 1px solid var(--text-faded);
47617- }
47618-
47619- div.calendar td:empty {
47620- background: var(--text-faded);
47621- opacity: 0.2;
47622- }
47623-
47624- div.calendar {
47625- display: flex;
47626- flex-wrap: wrap;
47627- flex-direction: row;
47628- gap: 1rem;
47629- align-items: baseline;
47630- }
47631-
47632- div.calendar caption {
47633- font-weight: bold;
47634- }
47635-
47636- div.entries {
47637- display: flex;
47638- flex-direction: column;
47639- }
47640-
47641- div.entries>p:first-child {
47642- margin: 1rem 0rem;
47643- }
47644-
47645- div.entries>div.entry {
47646- display: flex;
47647- flex-direction: column;
47648- gap: 0.5rem;
47649- border: 1px solid var(--border-secondary);
47650- padding: 1rem 1rem;
47651- }
47652-
47653- div.entries>div.entry+div.entry {
47654- border-top:none;
47655- }
47656-
47657- div.entries>div.entry>span.subject>a {
47658- /* increase surface area for clicks */
47659- padding: 1rem;
47660- margin: -1rem;
47661- }
47662-
47663- div.entries>div.entry span.metadata.replies {
47664- background: CanvasText;
47665- border-radius: .6rem;
47666- color: Canvas;
47667- padding: 0.1rem 0.4rem;
47668- font-size: small;
47669- font-variant-numeric: tabular-nums;
47670- }
47671-
47672- div.entries>div.entry>span.metadata {
47673- font-size: small;
47674- color: var(--text-faded);
47675- word-break: break-all;
47676- }
47677-
47678- div.entries>div.entry span.value {
47679- max-width: 44ch;
47680- display: inline-block;
47681- white-space: break-spaces;
47682- word-wrap: anywhere;
47683- word-break: break-all;
47684- vertical-align: top;
47685- }
47686-
47687- div.entries>div.entry span.value.empty {
47688- color: var(--text-faded);
47689- }
47690-
47691- div.posts>div.entry>span.metadata>span.from {
47692- margin-inline-end: 1rem;
47693- }
47694-
47695- table.headers {
47696- padding: .5rem 0 .5rem 1rem;
47697- }
47698-
47699- table.headers tr>th {
47700- text-align: left;
47701- color: var(--text-faded);
47702- }
47703-
47704- table.headers th[scope="row"] {
47705- padding-right: .5rem;
47706- vertical-align: top;
47707- font-family: var(--grotesque-system-stack);
47708- }
47709-
47710- table.headers tr>td {
47711- overflow-wrap: break-word;
47712- hyphens: auto;
47713- word-wrap: anywhere;
47714- word-break: break-all;
47715- width: auto;
47716- }
47717-
47718- div.post-body>pre {
47719- border-top: 1px solid;
47720- overflow-wrap: break-word;
47721- white-space: pre-line;
47722- hyphens: auto;
47723- /* background-color: var(--background-secondary); */
47724- line-height: 1.1;
47725- padding: 1rem;
47726- }
47727-
47728- div.post {
47729- border-top: 1px solid var(--horizontal-rule);
47730- border-right: 1px solid var(--horizontal-rule);
47731- border-left: 1px solid var(--horizontal-rule);
47732- border-bottom: 1px solid var(--horizontal-rule);
47733- }
47734- div.post:not(:first-child) {
47735- border-top: none;
47736- }
47737-
47738- td.message-id,
47739- span.message-id{
47740- color: var(--text-faded);
47741- }
47742- .message-id>a {
47743- overflow-wrap: break-word;
47744- hyphens: auto;
47745- }
47746- td.message-id:before,
47747- span.message-id:before{
47748- content: '<';
47749- display: inline-block;
47750- opacity: 0.6;
47751- }
47752- td.message-id:after,
47753- span.message-id:after{
47754- content: '>';
47755- display: inline-block;
47756- opacity: 0.6;
47757- }
47758- span.message-id + span.message-id:before{
47759- content: ', <';
47760- display: inline-block;
47761- opacity: 0.6;
47762- }
47763- td.faded,
47764- span.faded {
47765- color: var(--text-faded);
47766- }
47767- td.faded:is(:focus, :hover, :focus-visible, :focus-within),
47768- span.faded:is(:focus, :hover, :focus-visible, :focus-within) {
47769- color: revert;
47770- }
47771- tr>td>details.reply-details ~ tr {
47772- display: none;
47773- }
47774- tr>td>details.reply-details[open] ~ tr {
47775- display: revert;
47776- }
47777-
47778- ul.lists {
47779- padding: 1rem 2rem;
47780- }
47781-
47782- ul.lists li {
47783- list-style: disc;
47784- }
47785-
47786- ul.lists li + li {
47787- margin-top: 0.2rem;
47788- }
47789-
47790- dl.lists dt {
47791- font-weight: bold;
47792- font-size: 1.2rem;
47793- padding-bottom: .3em;
47794- background: #88929d36;
47795- padding: .2rem .2rem;
47796- border-radius: .2rem;
47797- }
47798-
47799- dl.lists dd > * + * {
47800- margin-top: 1rem;
47801- }
47802-
47803- dl.lists dd .list-topics,
47804- dl.lists dd .list-posts-dates {
47805- display: block;
47806- width: 100%;
47807- }
47808-
47809- dl.lists dl,
47810- dl.lists dd {
47811- font-size: small;
47812- }
47813-
47814- dl.lists dd {
47815- /* fallback in case margin-block-* is not supported */
47816- margin-bottom: 1rem;
47817- margin-block-start: 0.3rem;
47818- margin-block-end: 1rem;
47819- line-height: 1.5;
47820- }
47821-
47822- dl.lists .no-description {
47823- color: var(--text-faded);
47824- }
47825-
47826- hr {
47827- margin: 1rem 0rem;
47828- border-bottom: 1px solid #88929d;
47829- }
47830-
47831- .command-line-example {
47832- user-select: all;
47833- display: inline-block;
47834- ruby-align: center;
47835- ruby-position: under;
47836-
47837- background: var(--code-background);
47838- outline: 1px inset var(--code-background);
47839- border-radius: 1px;
47840- color: var(--code-foreground);
47841- font-weight: 500;
47842- width: auto;
47843- max-width: 90vw;
47844- padding: 1.2rem 0.8rem 1rem 0.8rem;
47845- overflow-wrap: break-word;
47846- overflow: auto;
47847- white-space: pre;
47848- }
47849-
47850- textarea.key-or-sig-input {
47851- font-family: var(--monospace-system-stack);
47852- font-size: 0.5rem;
47853- font-weight: 400;
47854- width: auto;
47855- height: 26rem;
47856- max-width: min(71ch, 100%);
47857- overflow-wrap: break-word;
47858- overflow: auto;
47859- white-space: pre;
47860- line-height: 1rem;
47861- vertical-align: top;
47862- }
47863-
47864- textarea.key-or-sig-input.wrap {
47865- word-wrap: anywhere;
47866- word-break: break-all;
47867- white-space: break-spaces;
47868- }
47869-
47870- .login-ssh textarea#id_password::placeholder {
47871- line-height: 1rem;
47872- }
47873-
47874- mark.ssh-challenge-token {
47875- font-family: var(--monospace-system-stack);
47876- overflow-wrap: anywhere;
47877- }
47878-
47879- .body-grid {
47880- display: grid;
47881- /* fallback */
47882- grid-template-columns: 1fr;
47883- grid-template-columns: fit-content(100%);
47884- grid-auto-rows: min-content;
47885- row-gap: min(6vw, 1rem);
47886- width: 100%;
47887- height: 100%;
47888- }
47889-
47890- form.login-form {
47891- display: flex;
47892- flex-direction: column;
47893- gap: 8px;
47894- max-width: 98vw;
47895- width: auto;
47896- }
47897-
47898- form.login-form > :not([type="hidden"]) + label, fieldset > :not([type="hidden"], legend) + label {
47899- margin-top: 1rem;
47900- }
47901-
47902- form.settings-form {
47903- display: grid;
47904- grid-template-columns: auto;
47905- gap: 1rem;
47906- max-width: 90vw;
47907- width: auto;
47908- overflow: auto;
47909- }
47910-
47911- form.settings-form>input[type="submit"] {
47912- place-self: start;
47913- }
47914-
47915- form.settings-form>fieldset {
47916- padding: 1rem 1.5rem 2rem 1.5rem;
47917- }
47918-
47919- form.settings-form>fieldset>legend {
47920- padding: .5rem 1rem;
47921- border: 1px ridge var(--text-faded);
47922- font-weight: bold;
47923- font-size: small;
47924- margin-left: 0.8rem;
47925- }
47926-
47927- form.settings-form>fieldset>div {
47928- display: flex;
47929- flex-direction: row;
47930- flex-wrap: nowrap;
47931- align-items: center;
47932- }
47933-
47934- form.settings-form>fieldset>div>label:last-child {
47935- padding: 1rem 0 1rem 1rem;
47936- flex-grow: 2;
47937- max-width: max-content;
47938- }
47939-
47940- form.settings-form>fieldset>div>label:first-child {
47941- padding: 1rem 1rem 1rem 0rem;
47942- flex-grow: 2;
47943- max-width: max-content;
47944- }
47945-
47946- form.settings-form>fieldset>div>:not(label):not(input) {
47947- flex-grow: 8;
47948- width: auto;
47949- }
47950-
47951- form.settings-form>fieldset>div>input {
47952- margin: 0.8rem;
47953- }
47954-
47955- form.settings-form>fieldset>table tr>th {
47956- text-align: right;
47957- padding-right: 1rem;
47958- }
47959-
47960- button, input {
47961- overflow: visible;
47962- }
47963-
47964- button, input, optgroup, select, textarea {
47965- font-family: inherit;
47966- font-size: 100%;
47967- line-height: 1.15;
47968- margin: 0;
47969- }
47970-
47971- form label {
47972- font-weight: 500;
47973- }
47974-
47975- textarea {
47976- max-width: var(--main-width);
47977- width: 100%;
47978- resize: both;
47979- }
47980- textarea {
47981- overflow: auto;
47982- }
47983-
47984- button, [type="button"], [type="reset"], [type="submit"] {
47985- -webkit-appearance: button;
47986- }
47987-
47988- input, textarea {
47989- display: inline-block;
47990- appearance: auto;
47991- -moz-default-appearance: textfield;
47992- padding: 1px;
47993- border: 2px inset ButtonBorder;
47994- border-radius: 5px;
47995- padding: .5rem;
47996- background-color: Field;
47997- color: FieldText;
47998- font: -moz-field;
47999- text-rendering: optimizeLegibility;
48000- cursor: text;
48001- }
48002-
48003- input[type="text"], textarea {
48004- outline: 3px inset #6969694a;
48005- outline-offset: -5px;
48006- }
48007-
48008- button, ::file-selector-button, input:is([type="color"], [type="reset"], [type="button"], [type="submit"]) {
48009- appearance: auto;
48010- -moz-default-appearance: button;
48011- padding-block: 1px;
48012- padding-inline: 8px;
48013- border: 2px outset ButtonBorder;
48014- border-radius: 3px;
48015- background-color: ButtonFace;
48016- cursor: default;
48017- box-sizing: border-box;
48018- user-select: none;
48019- padding: .5rem;
48020- min-width: 10rem;
48021- align-self: start;
48022- }
48023-
48024- button:disabled, input:is([type="color"], [type="reset"], [type="button"], [type="submit"]):disabled {
48025- color: var(--text-faded);
48026- background: Field;
48027- cursor: not-allowed;
48028- }
48029-
48030- ol.list {
48031- list-style: decimal outside;
48032- padding-inline-start: 4rem;
48033- }
48034-
48035- .screen-reader-only {
48036- position:absolute;
48037- left:-500vw;
48038- top:auto;
48039- width:1px;
48040- height:1px;
48041- overflow:hidden;
48042- }
48043-
48044- ul.tags {
48045- list-style: none;
48046- margin: 0;
48047- padding: 0;
48048- height: max-content;
48049- vertical-align: baseline;
48050- display: inline-flex;
48051- gap: 0.8ex;
48052- flex-flow: row wrap;
48053- }
48054-
48055- .tag {
48056- --aa-brightness: calc(((var(--red) * 299) + (var(--green) * 587) + (var(--blue) * 114)) / 1000);
48057- --aa-color: calc((var(--aa-brightness) - 128) * -1000);
48058-
48059- --padding-top-bottom: 0.2rem;
48060- --padding-left-right: .5rem;
48061- --padding-top-bottom: 0.5rem;
48062- --height: calc(1.5cap + var(--padding-top-bottom));
48063- /* fallback */
48064- max-height: 1rem;
48065- max-height: var(--height);
48066- min-height: 1.45rem;
48067- /* fallback */
48068- line-height: 1.3;
48069- line-height: calc(var(--height) / 2);
48070- min-width: max-content;
48071- /* fallback */
48072- min-height: 1rem;
48073- min-height: var(--height);
48074-
48075- display: inline-block;
48076- border: 1px solid var(--tag-border-color);
48077- border-radius:.2rem;
48078- color: #555;
48079- font-size: 1.05rem;
48080- padding: calc(var(--padding-top-bottom) / 2) var(--padding-left-right) var(--padding-top-bottom) var(--padding-left-right);
48081- text-decoration: none;
48082- background: rgb(var(--red), var(--green), var(--blue));
48083- color: rgb(var(--aa-color), var(--aa-color), var(--aa-color));
48084- }
48085-
48086- span.tag-name a {
48087- text-decoration: none;
48088- color: inherit;
48089- }
48090-
48091- blockquote {
48092- margin-inline: 0 var(--gap);
48093- padding-inline: var(--gap) 0;
48094- margin-block: var(--gap);
48095- font-size: 1.1em;
48096- line-height: var(--rhythm);
48097- font-style: italic;
48098- border-inline-start: 1px solid var(--graphical-fg);
48099- color: var(--muted-fg);
48100- }
48101-
48102- time, .tabular-nums {
48103- font-family: var(--grotesque-system-stack);
48104- font-variant-numeric: tabular-nums slashed-zero;
48105- }
48106-
48107- a[href^="#"].anchor::before {
48108- color: var(--text-inactive);
48109- content: "#";
48110- display: inline-block;
48111- font-size: .7em;
48112- line-height: 1;
48113- margin-left: -.8em;
48114- text-decoration: none;
48115- visibility: hidden;
48116- width: .8em;
48117- }
48118- </style>
48119 diff --git a/web/src/templates/footer.html b/web/src/templates/footer.html
48120deleted file mode 100644
48121index 15b74a9..0000000
48122--- a/web/src/templates/footer.html
48123+++ /dev/null
48124 @@ -1,6 +0,0 @@
48125- <footer>
48126- <p>Generated by <a href="https://github.com/meli/mailpot" target="_blank">mailpot</a>.</p>
48127- </footer>
48128- </main>
48129- </body>
48130- </html>
48131 diff --git a/web/src/templates/header.html b/web/src/templates/header.html
48132deleted file mode 100644
48133index d4ad75e..0000000
48134--- a/web/src/templates/header.html
48135+++ /dev/null
48136 @@ -1,35 +0,0 @@
48137- <!DOCTYPE html>
48138- <html lang="en">
48139- <head>
48140- <meta charset="utf-8">
48141- <meta name="viewport" content="width=device-width, initial-scale=1">
48142- <title>{{ title if title else page_title if page_title else site_title }}</title>{% if canonical_url %}
48143- <link href="{{ urlize(canonical_url) }}" rel="canonical" />{% endif %}
48144- {% include "css.html" %}
48145- </head>
48146- <body>
48147- <main class="layout">
48148- <div class="header">
48149- <h1><bdi>{{ site_title }}</bdi></h1>
48150- {% if site_subtitle %}
48151- <p class="site-subtitle"><bdi>{{ site_subtitle|safe }}</bdi></p>
48152- {% endif %}
48153- {% include "menu.html" %}
48154- <div class="page-header">
48155- {% if crumbs|length > 1 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs">
48156- <ol id="breadcrumb-menu" role="menu" aria-label="Breadcrumb menu">{% for crumb in crumbs %}<li class="crumb" aria-describedby="bread_{{ loop.index }}">{% if loop.last %}<span role="menuitem" id="bread_{{ loop.index }}" aria-current="page" title="current page">{{ crumb.label }}</span>{% else %}<a role="menuitem" id="bread_{{ loop.index }}" href="{{ urlize(crumb.url) }}" tabindex="0">{{ crumb.label }}</a>{% endif %}</li>{% endfor %}</ol>
48157- </nav>{% endif %}
48158- {% if page_title %}
48159- <h2 class="page-title"><bdi>{{ page_title }}</bdi></h2>
48160- {% endif %}
48161- {% if messages %}
48162- <ul class="messagelist">
48163- {% for message in messages %}
48164- <li class="{{ message.level|lower }}">
48165- <span class="label">{{ message.level }}: </span>{{ message.message }}
48166- </li>
48167- {% endfor %}
48168- </ul>
48169- {% endif %}
48170- </div>
48171- </div>
48172 diff --git a/web/src/templates/help.html b/web/src/templates/help.html
48173deleted file mode 100644
48174index 3c846ae..0000000
48175--- a/web/src/templates/help.html
48176+++ /dev/null
48177 @@ -1,20 +0,0 @@
48178- {% include "header.html" %}
48179- <div class="body body-grid">
48180- {{ heading(3, "Subscribing to a list") }}
48181-
48182- <p>A mailing list can have different subscription policies, or none at all (which would disable subscriptions). If subscriptions are open or require manual approval by the list owners, you can send an e-mail request to its <code>+request</code> sub-address with the subject <code>subscribe</code>.</p>
48183-
48184- {{ heading(3, "Unsubscribing from a list") }}
48185-
48186- <p>Similarly to subscribing, send an e-mail request to the list's <code>+request</code> sub-address with the subject <code>unsubscribe</code>.</p>
48187-
48188- {{ heading(3, "Do I need an account?") }}
48189-
48190- <p>An account's utility is only to manage your subscriptions and preferences from the web interface. Thus you don't need one if you want to perform all list operations from your e-mail client instead.</p>
48191-
48192- {{ heading(3, "Creating an account") }}
48193-
48194- <p>After successfully subscribing to a list, simply send an e-mail request to its <code>+request</code> sub-address with the subject <code>password</code> and an SSH public key in the e-mail body as plain text.</p>
48195- <p>This will either create you an account with this key, or change your existing key if you already have one.</p>
48196- </div>
48197- {% include "footer.html" %}
48198 diff --git a/web/src/templates/index.html b/web/src/templates/index.html
48199deleted file mode 100644
48200index c2a6c97..0000000
48201--- a/web/src/templates/index.html
48202+++ /dev/null
48203 @@ -1,11 +0,0 @@
48204- {% include "header.html" %}
48205- <div class="entry">
48206- <div class="body">
48207- <ul>
48208- {% for l in lists %}
48209- <li><a href="{{ list_path(l.list.id) }}"><bdi>{{ l.list.name }}</bdi></a></li>
48210- {% endfor %}
48211- </ul>
48212- </div>
48213- </div>
48214- {% include "footer.html" %}
48215 diff --git a/web/src/templates/lists.html b/web/src/templates/lists.html
48216deleted file mode 100644
48217index 5f1a6d8..0000000
48218--- a/web/src/templates/lists.html
48219+++ /dev/null
48220 @@ -1,13 +0,0 @@
48221- {% include "header.html" %}
48222- <div class="body">
48223- <!-- {{ lists|length }} lists -->
48224- <div class="entry">
48225- <dl class="lists" aria-label="list of mailing lists">
48226- {% for l in lists %}
48227- <dt aria-label="mailing list name"><a href="{{ list_path(l.list.id) }}"><bdi>{{ l.list.name }}</bdi></a></dt>
48228- <dd><span aria-label="mailing list description"{% if not l.list.description %} class="no-description"{% endif %}>{{ l.list.description if l.list.description else "<p>no description</p>"|safe }}</span><span class="list-posts-dates tabular-nums">{{ l.posts|length }} post{{ l.posts|length|pluralize("","s") }}{% if l.newest %} | <time datetime="{{ l.newest }}">{{ l.newest }}</time>{% endif %}</span>{% if l.list.topics|length > 0 %}<span class="list-topics"><span>Topics:</span>&nbsp;{{ l.list.topics() }}</span>{% endif %}</dd>
48229- {% endfor %}
48230- </dl>
48231- </div>
48232- </div>
48233- {% include "footer.html" %}
48234 diff --git a/web/src/templates/lists/edit.html b/web/src/templates/lists/edit.html
48235deleted file mode 100644
48236index 02c3ef3..0000000
48237--- a/web/src/templates/lists/edit.html
48238+++ /dev/null
48239 @@ -1,156 +0,0 @@
48240- {% include "header.html" %}
48241- <div class="body body-grid">
48242- {{ heading(3, "Edit <a href=\"" ~list_path(list.id) ~ "\">"~ list.id ~"</a>","edit") }}
48243- <address>
48244- {{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
48245- </address>
48246- {% if list.description %}
48247- {% if list.is_description_html_safe %}
48248- {{ list.description|safe}}
48249- {% else %}
48250- <p>{{ list.description }}</p>
48251- {% endif %}
48252- {% endif %}
48253- {% if list.archive_url %}
48254- <p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
48255- {% endif %}
48256- <p><a href="{{ list_subscribers_path(list.id) }}">{{ subs_count }} subscription{{ subs_count|pluralize }}.</a></p>
48257- <p><a href="{{ list_candidates_path(list.id) }}">{{ sub_requests_count }} subscription request{{ sub_requests_count|pluralize }}.</a></p>
48258- <p>{{ post_count }} post{{ post_count|pluralize }}.</p>
48259- <form method="post" class="settings-form">
48260- <fieldset>
48261- <input type="hidden" name="type" value="metadata">
48262- <legend>List Metadata</legend>
48263-
48264- <table>
48265- <tr>
48266- <th>
48267- <label for="id_name">List name.</label>
48268- </th>
48269- <td>
48270- <input type="text" name="name" id="id_name" value="{{ list.name }}">
48271- </td>
48272- </tr>
48273- <tr>
48274- <th>
48275- <label for="id_list_id">List ID.</label>
48276- </th>
48277- <td>
48278- <input type="text" name="id" id="id_list_id" value="{{ list.id }}">
48279- </td>
48280- </tr>
48281- <tr>
48282- <th>
48283- <label for="id_description">List description.</label>
48284- </th>
48285- <td>
48286- <textarea name="description" id="id_description">{{ list.description if list.description else "" }}</textarea>
48287- </td>
48288- </tr>
48289- <tr>
48290- <th>
48291- <label for="id_list_address">List address.</label>
48292- </th>
48293- <td>
48294- <input type="email" name="address" id="id_list_address" value="{{ list.address }}">
48295- </td>
48296- </tr>
48297- <tr>
48298- <th>
48299- <label for="id_owner_local_part">List owner local part.</label>
48300- </th>
48301- <td>
48302- <input type="text" name="owner_local_part" id="id_owner_local_part" value="{{ list.owner_local_part if list.owner_local_part else "" }}">
48303- </td>
48304- </tr>
48305- <tr>
48306- <th>
48307- <label for="id_request_local_part">List request local part.</label>
48308- </th>
48309- <td>
48310- <input type="text" name="request_local_part" id="id_request_local_part" value="{{ list.request_local_part if list.request_local_part else "" }}">
48311- </td>
48312- </tr>
48313- <tr>
48314- <th>
48315- <label for="id_archive_url">List archive URL.</label>
48316- </th>
48317- <td>
48318- <input type="text" name="archive_url" id="id_archive_url" value="{{ list.archive_url if list.archive_url else "" }}">
48319- </td>
48320- </tr>
48321- </table>
48322- </fieldset>
48323-
48324- <input type="submit" name="metadata" value="Update list">
48325- </form>
48326- <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
48327- <fieldset>
48328- <input type="hidden" name="type" value="post-policy">
48329- <legend>Post Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>
48330- {% if not post_policy %}
48331- <ul class="messagelist">
48332- <li class="info">
48333- <span class="label">Info: </span>No post policy set. Press Create to add one.
48334- </li>
48335- </ul>
48336- {% endif %}
48337- <div>
48338- <input type="radio" required="" name="post-policy" id="post-announce-only" value="announce-only"{% if post_policy.announce_only %} checked{% endif %}>
48339- <label for="post-announce-only">Announce only</label>
48340- </div>
48341- <div>
48342- <input type="radio" required="" name="post-policy" id="post-subscription-only" value="subscription-only"{% if post_policy.subscription_only %} checked{% endif %}>
48343- <label for="post-subscription-only">Subscription only</label>
48344- </div>
48345- <div>
48346- <input type="radio" required="" name="post-policy" id="post-approval-needed" value="approval-needed"{% if post_policy.approval_needed %} checked{% endif %}>
48347- <label for="post-approval-needed">Approval needed</label>
48348- </div>
48349- <div>
48350- <input type="radio" required="" name="post-policy" id="post-open" value="open"{% if post_policy.open %} checked{% endif %}>
48351- <label for="post-open">Open</label>
48352- </div>
48353- <div>
48354- <input type="radio" required="" name="post-policy" id="post-custom" value="custom"{% if post_policy.custom %} checked{% endif %}>
48355- <label for="post-custom">Custom</label>
48356- </div>
48357- </fieldset>
48358- <input type="submit" value="{{ "Update" if post_policy else "Create" }} Post Policy">
48359- </form>
48360- <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
48361- <fieldset>
48362- <input type="hidden" name="type" value="subscription-policy">
48363- <legend>Subscription Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>
48364- {% if not subscription_policy %}
48365- <ul class="messagelist">
48366- <li class="info">
48367- <span class="label">Info: </span>No subscription policy set. Press Create to add one.
48368- </li>
48369- </ul>
48370- {% endif %}
48371- <div>
48372- <input type="checkbox" value="true" name="send-confirmation" id="sub-send-confirmation"{% if subscription_policy.send_confirmation %} checked{% endif %}>
48373- <label for="sub-send-confirmation">Send confirmation to new subscribers.</label>
48374- </div>
48375- <div>
48376- <input type="radio" required="" name="subscription-policy" id="sub-open" value="open"{% if subscription_policy.open %} checked{% endif %}>
48377- <label for="sub-open">Open</label>
48378- </div>
48379- <div>
48380- <input type="radio" required="" name="subscription-policy" id="sub-manual" value="manual"{% if subscription_policy.manual %} checked{% endif %}>
48381- <label for="sub-manual">Manual</label>
48382- </div>
48383- <div>
48384- <input type="radio" required="" name="subscription-policy" id="sub-request" value="request"{% if subscription_policy.request %} checked{% endif %}>
48385- <label for="sub-request">Request</label>
48386- </div>
48387- <div>
48388- <input type="radio" required="" name="subscription-policy" id="sub-custom" value="custom"{% if subscription_policy.custom %} checked{% endif %}>
48389- <label for="sub-custom">Custom</label>
48390- </div>
48391- </fieldset>
48392- <input type="submit" value="{{ "Update" if subscription_policy else "Create" }} Subscription Policy">
48393- </form>
48394- </div>
48395- {% include "footer.html" %}
48396 diff --git a/web/src/templates/lists/entry.html b/web/src/templates/lists/entry.html
48397deleted file mode 100644
48398index 6920257..0000000
48399--- a/web/src/templates/lists/entry.html
48400+++ /dev/null
48401 @@ -1,39 +0,0 @@
48402- <div class="post" id="{{ strip_carets(post.message_id)|safe }}">
48403- <table class="headers" title="E-mail headers">
48404- <caption class="screen-reader-only">E-mail headers</caption>
48405- <tr>
48406- <th scope="row"></th>
48407- <td><a href="#{{ strip_carets(post.message_id) }}"></a></td>
48408- </tr>
48409- <tr>
48410- <th scope="row">From:</th>
48411- <td><bdi>{{ post.address }}</bdi></td>
48412- </tr>
48413- <tr>
48414- <th scope="row">Date:</th>
48415- <td class="faded">{{ post.datetime }}</td>
48416- </tr>
48417- <tr>
48418- <th scope="row">Message-ID:</th>
48419- <td class="faded message-id"><a href="{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
48420- </tr>
48421- {% if in_reply_to %}
48422- <tr>
48423- <th scope="row">In-Reply-To:</th>
48424- <td class="faded message-id"><a href="{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
48425- </tr>
48426- {% endif %}
48427- {% if references %}
48428- <tr>
48429- <th scope="row">References:</th>
48430- <td>{% for r in references %}<span class="faded message-id"><a href="{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
48431- </tr>
48432- {% endif %}
48433- <tr>
48434- <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>
48435- </tr>
48436- </table>
48437- <div class="post-body">
48438- <pre {% if odd %}style="--background-secondary: var(--background-critical);" {% endif %}title="E-mail text content">{{ body|trim }}</pre>
48439- </div>
48440- </div>
48441 diff --git a/web/src/templates/lists/list.html b/web/src/templates/lists/list.html
48442deleted file mode 100644
48443index 18fe31a..0000000
48444--- a/web/src/templates/lists/list.html
48445+++ /dev/null
48446 @@ -1,114 +0,0 @@
48447- {% include "header.html" %}
48448- <div class="body">
48449- {% if list.topics|length > 0 %}<span><em>Topics</em>:</span>&nbsp;{{ list.topics() }}
48450- <br aria-hidden="true">
48451- <br aria-hidden="true">
48452- {% endif %}
48453- {% if list.description %}
48454- <p title="mailing list description">{{ list.description }}</p>
48455- {% else %}
48456- <p title="mailing list description">No list description.</p>
48457- {% endif %}
48458- <br aria-hidden="true">
48459- {% if current_user and subscription_policy and subscription_policy.open %}
48460- {% if user_context %}
48461- <form method="post" action="{{ settings_path() }}" class="settings-form">
48462- <input type="hidden" name="type", value="unsubscribe">
48463- <input type="hidden" name="list_pk", value="{{ list.pk }}">
48464- <input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
48465- </form>
48466- <br />
48467- {% else %}
48468- <form method="post" action="{{ settings_path() }}" class="settings-form">
48469- <input type="hidden" name="type", value="subscribe">
48470- <input type="hidden" name="list_pk", value="{{ list.pk }}">
48471- <input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
48472- </form>
48473- <br />
48474- {% endif %}
48475- {% endif %}
48476- {% if preamble %}
48477- <section id="preamble" class="preamble" aria-label="mailing list instructions">
48478- {% if preamble.custom %}
48479- {{ preamble.custom|safe }}
48480- {% else %}
48481- {% if subscription_policy %}
48482- {% if subscription_policy.open or subscription_policy.request %}
48483- {{ heading(3, "Subscribe") }}
48484- {% set subscription_mailto=list.subscription_mailto() %}
48485- {% if subscription_mailto %}
48486- {% if subscription_mailto.subject %}
48487- <p>
48488- <a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code>
48489- </p>
48490- {% else %}
48491- <p>
48492- <a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
48493- </p>
48494- {% endif %}
48495- {% else %}
48496- <p>List is not open for subscriptions.</p>
48497- {% endif %}
48498-
48499- {% set unsubscription_mailto=list.unsubscription_mailto() %}
48500- {% if unsubscription_mailto %}
48501- {{ heading(3, "Unsubscribe") }}
48502- {% if unsubscription_mailto.subject %}
48503- <p>
48504- <a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
48505- </p>
48506- {% else %}
48507- <p>
48508- <a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
48509- </p>
48510- {% endif %}
48511- {% endif %}
48512- {% endif %}
48513- {% endif %}
48514-
48515- {% if post_policy %}
48516- {{ heading(3, "Post") }}
48517- {% if post_policy.announce_only %}
48518- <p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
48519- {% elif post_policy.subscription_only %}
48520- <p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
48521- <p>If you are subscribed, you can send new posts to:
48522- <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
48523- </p>
48524- {% elif post_policy.approval_needed or post_policy.no_subscriptions %}
48525- <p>List is open to all posts <em>after approval</em> by the list owners.</p>
48526- <p>You can send new posts to:
48527- <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
48528- </p>
48529- {% else %}
48530- <p>List is not open for submissions.</p>
48531- {% endif %}
48532- {% endif %}
48533- {% endif %}
48534- </section>
48535- {% endif %}
48536- <section class="list" aria-hidden="true">
48537- {{ heading(3, "Calendar") }}
48538- <div class="calendar">
48539- {%- from "calendar.html" import cal %}
48540- {% for date in months %}
48541- {{ cal(date, hists) }}
48542- {% endfor %}
48543- </div>
48544- </section>
48545- <section aria-label="mailing list posts">
48546- {{ heading(3, "Posts") }}
48547- <div class="posts entries" role="list" aria-label="list of mailing list posts">
48548- <p>{{ posts | length }} post{{ posts|length|pluralize }}</p>
48549- {% for post in posts %}
48550- <div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}">
48551- <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
48552- <span class="metadata"><span aria-hidden="true">👤&nbsp;</span><span class="from" title="post author"><bdi>{{ post.address }}</bdi></span><span aria-hidden="true"> 📆&nbsp;</span><span class="date" title="post date">{{ post.datetime }}</span></span>
48553- {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">&#x1F493;&nbsp;</span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %}
48554- <span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>
48555- </div>
48556- {% endfor %}
48557- </div>
48558- </section>
48559- </div>
48560- {% include "footer.html" %}
48561 diff --git a/web/src/templates/lists/post.html b/web/src/templates/lists/post.html
48562deleted file mode 100644
48563index a0d07e5..0000000
48564--- a/web/src/templates/lists/post.html
48565+++ /dev/null
48566 @@ -1,13 +0,0 @@
48567- {% include "header.html" %}
48568- <div class="body">
48569- {% set is_root = true %}
48570- {% with post = { 'address': from, 'to': to, 'datetime': date, 'message_id': message_id } %}
48571- {% include 'lists/entry.html' %}
48572- {% endwith %}
48573- {% set is_root = false %}
48574- {% for (depth, post, body, date) in thread %}
48575- {% set odd = loop.index % 2 == 1 %}
48576- {% include 'lists/entry.html' %}
48577- {% endfor %}
48578- </div>
48579- {% include "footer.html" %}
48580 diff --git a/web/src/templates/lists/sub-requests.html b/web/src/templates/lists/sub-requests.html
48581deleted file mode 100644
48582index 72d6137..0000000
48583--- a/web/src/templates/lists/sub-requests.html
48584+++ /dev/null
48585 @@ -1,57 +0,0 @@
48586- {% include "header.html" %}
48587- <div class="body body-grid">
48588- <style>
48589- table {
48590- border-collapse: collapse;
48591- border: 2px solid rgb(200,200,200);
48592- letter-spacing: 1px;
48593- }
48594-
48595- td, th {
48596- border: 1px solid rgb(190,190,190);
48597- padding: 0.1rem 1rem;
48598- }
48599-
48600- th {
48601- background-color: var(--background-tertiary);
48602- }
48603-
48604- td {
48605- text-align: center;
48606- }
48607-
48608- caption {
48609- padding: 10px;
48610- }
48611- </style>
48612- <p>{{ subs|length }} entr{{ subs|length|pluralize("y","ies") }}.</a></p>
48613- {% if subs %}
48614- <div style="overflow: scroll;">
48615- <table>
48616- <tr>
48617- {% for key,val in subs|first|items %}
48618- <th>{{ key }}</th>
48619- {% endfor %}
48620- <th></th>
48621- </tr>
48622- {% for s in subs %}
48623- <tr>
48624- {% for key,val in s|items %}
48625- <td>{{ val }}</td>
48626- {% endfor %}
48627- <td>
48628- {% if not s.accepted %}
48629- <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
48630- <input type="hidden" name="type" value="accept-subscription-request">
48631- <input type="hidden" name="pk" value="{{ s.pk }}">
48632- <input type="submit" value="Accept">
48633- </form>
48634- {% endif %}
48635- </td>
48636- </tr>
48637- {% endfor %}
48638- </table>
48639- </div>
48640- {% endif %}
48641- </div>
48642- {% include "footer.html" %}
48643 diff --git a/web/src/templates/lists/subs.html b/web/src/templates/lists/subs.html
48644deleted file mode 100644
48645index 3b7cc7c..0000000
48646--- a/web/src/templates/lists/subs.html
48647+++ /dev/null
48648 @@ -1,47 +0,0 @@
48649- {% include "header.html" %}
48650- <div class="body body-grid">
48651- <style>
48652- table {
48653- border-collapse: collapse;
48654- border: 2px solid rgb(200,200,200);
48655- letter-spacing: 1px;
48656- }
48657-
48658- td, th {
48659- border: 1px solid rgb(190,190,190);
48660- padding: 0.1rem 1rem;
48661- }
48662-
48663- th {
48664- background-color: var(--background-tertiary);
48665- }
48666-
48667- td {
48668- text-align: center;
48669- }
48670-
48671- caption {
48672- padding: 10px;
48673- }
48674- </style>
48675- <p>{{ subs|length }} entr{{ subs|length|pluralize("y","ies") }}.</a></p>
48676- {% if subs %}
48677- <div style="overflow: scroll;">
48678- <table>
48679- <tr>
48680- {% for key,val in subs|first|items %}
48681- <th>{{ key }}</th>
48682- {% endfor %}
48683- </tr>
48684- {% for s in subs %}
48685- <tr>
48686- {% for key,val in s|items %}
48687- <td>{{ val }}</td>
48688- {% endfor %}
48689- </tr>
48690- {% endfor %}
48691- </table>
48692- </div>
48693- {% endif %}
48694- </div>
48695- {% include "footer.html" %}
48696 diff --git a/web/src/templates/menu.html b/web/src/templates/menu.html
48697deleted file mode 100644
48698index ea9b627..0000000
48699--- a/web/src/templates/menu.html
48700+++ /dev/null
48701 @@ -1,11 +0,0 @@
48702- <nav class="main-nav" aria-label="main menu" role="menu">
48703- <ul>
48704- <li><a role="menuitem" href="{{ urlize("") }}/">Index</a></li>
48705- <li><a role="menuitem" href="{{ help_path() }}">Help&nbsp;&amp; Documentation</a></li>
48706- {% if current_user %}
48707- <li class="push">Settings: <a role="menuitem" href="{{ settings_path() }}" title="User settings"><bdi>{{ current_user.address }}</bdi></a></li>
48708- {% else %}
48709- <li class="push"><a role="menuitem" href="{{ login_path() }}" title="login with one time password using your SSH key">Login with SSH OTP</a></li>
48710- {% endif %}
48711- </ul>
48712- </nav>
48713 diff --git a/web/src/templates/settings.html b/web/src/templates/settings.html
48714deleted file mode 100644
48715index 1a6bdc0..0000000
48716--- a/web/src/templates/settings.html
48717+++ /dev/null
48718 @@ -1,83 +0,0 @@
48719- {% include "header.html" %}
48720- <div class="body body-grid">
48721- {{ heading(3,"Your account","account") }}
48722- <div class="entries">
48723- <div class="entry">
48724- <span>Display name: <span class="value{% if not user.name %} empty{% endif %}"><bdi>{{ user.name if user.name else "None" }}</bdi></span></span>
48725- </div>
48726- <div class="entry">
48727- <span>Address: <span class="value">{{ user.address }}</span></span>
48728- </div>
48729- <div class="entry">
48730- <span>PGP public key: <span class="value{% if not user.public_key %} empty{% endif %}">{{ user.public_key if user.public_key else "None." }}</span></span>
48731- </div>
48732- <div class="entry">
48733- <span>SSH public key: <span class="value{% if not user.password %} empty{% endif %}">{{ user.password if user.password else "None." }}</span></span>
48734- </div>
48735- </div>
48736-
48737- {{ heading(4,"List Subscriptions") }}
48738- <div class="entries">
48739- <p>{{ subscriptions | length }} subscription(s)</p>
48740- {% for (s, list) in subscriptions %}
48741- <div class="entry">
48742- <span class="subject"><a href="{{ list_settings_path(list.id) }}">{{ list.name }}</a></span>
48743- <!-- span class="metadata">📆&nbsp;<span>{{ s.created }}</span></span -->
48744- </div>
48745- {% endfor %}
48746- </div>
48747-
48748- {{ heading(4,"Account Settings") }}
48749- <form method="post" action="{{ settings_path() }}" class="settings-form">
48750- <input type="hidden" name="type" value="change-name">
48751- <fieldset>
48752- <legend>Change display name</legend>
48753-
48754- <div>
48755- <label for="id_name">New name:</label>
48756- <input type="text" name="new" id="id_name" value="{{ user.name if user.name else "" }}">
48757- </div>
48758- </fieldset>
48759- <input type="submit" name="change" value="Change">
48760- </form>
48761-
48762- <form method="post" action="{{ settings_path() }}" class="settings-form">
48763- <input type="hidden" name="type" value="change-password">
48764- <fieldset>
48765- <legend>Change SSH public key</legend>
48766-
48767- <div>
48768- <label for="id_ssh_public_key">New SSH public key:</label>
48769- <textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_ssh_public_key">{{ user.password if user.password else "" }}</textarea>
48770- </div>
48771- </fieldset>
48772- <input type="submit" name="change" value="Change">
48773- </form>
48774-
48775- <form method="post" action="{{ settings_path() }}" class="settings-form">
48776- <input type="hidden" name="type" value="change-public-key">
48777- <fieldset>
48778- <legend>Change PGP public key</legend>
48779-
48780- <div>
48781- <label for="id_public_key">New PGP public key:</label>
48782- <textarea class="key-or-sig-input wrap" required="" cols="15" rows="5" name="new" id="id_public_key">{{ user.public_key if user.public_key else "" }}</textarea>
48783- </div>
48784- </fieldset>
48785- <input type="submit" name="change-public-key" value="Change">
48786- </form>
48787-
48788- <form method="post" action="{{ settings_path() }}" class="settings-form">
48789- <input type="hidden" name="type" value="remove-public-key">
48790- <fieldset>
48791- <legend>Remove PGP public key</legend>
48792-
48793- <div>
48794- <input type="checkbox" required="" name="remove-public-keyim-sure" id="remove-public-key-im-sure">
48795- <label for="remove-public-key-im-sure">I am certain I want to remove my PGP public key.</label>
48796- </div>
48797- </fieldset>
48798- <input type="submit" name="remove-public-key" value="Remove">
48799- </form>
48800- </div>
48801- {% include "footer.html" %}
48802 diff --git a/web/src/templates/settings_subscription.html b/web/src/templates/settings_subscription.html
48803deleted file mode 100644
48804index e36d187..0000000
48805--- a/web/src/templates/settings_subscription.html
48806+++ /dev/null
48807 @@ -1,61 +0,0 @@
48808- {% include "header.html" %}
48809- <div class="body body-grid">
48810- {{ heading(3, "Your subscription to <a href=\"" ~ list_path(list.id) ~ "\">" ~ list.id ~ "</a>.","subscription") }}
48811- <address>
48812- <bdi>{{ list.name }}</bdi> <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
48813- </address>
48814- {% if list.is_description_html_safe %}
48815- {{ list.description|safe}}
48816- {% else %}
48817- <p><bdi>{{ list.description }}</bdi></p>
48818- {% endif %}
48819- {% if list.archive_url %}
48820- <p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
48821- {% endif %}
48822- <form method="post" class="settings-form">
48823- <fieldset>
48824- <legend>subscription settings</legend>
48825-
48826- <div>
48827- <input type="checkbox" value="true" name="digest" id="id_digest"{% if subscription.digest %} checked{% endif %}>
48828- <label for="id_digest">Receive posts as a digest.</label>
48829- </div>
48830-
48831- <div>
48832- <input type="checkbox" value="true" name="hide_address" id="id_hide_address"{% if subscription.hide_address %} checked{% endif %}>
48833- <label for="id_hide_address">Hide your e-mail address in your posts.</label>
48834- </div>
48835-
48836- <div>
48837- <input type="checkbox" value="true" name="receive_duplicates" id="id_receive_duplicates"{% if subscription.receive_duplicates %} checked{% endif %}>
48838- <label for="id_receive_duplicates">Receive mailing list post duplicates, <abbr title="that is">i.e.</abbr> posts addressed both to you and the mailing list to which you are subscribed.</label>
48839- </div>
48840-
48841- <div>
48842- <input type="checkbox" value="true" name="receive_own_posts" id="id_receive_own_posts"{% if subscription.receive_own_posts %} checked{% endif %}>
48843- <label for="id_receive_own_posts">Receive your own mailing list posts from the mailing list.</label>
48844- </div>
48845-
48846- <div>
48847- <input type="checkbox" value="true" name="receive_confirmation" id="id_receive_confirmation"{% if subscription.receive_confirmation %} checked{% endif %}>
48848- <label for="id_receive_confirmation">Receive a plain confirmation for your own mailing list posts.</label>
48849- </div>
48850- </fieldset>
48851-
48852- <input type="submit" value="Update settings">
48853- <input type="hidden" name="next" value="">
48854- </form>
48855- <form method="post" action="{{ settings_path() }}" class="settings-form">
48856- <fieldset>
48857- <input type="hidden" name="type" value="unsubscribe">
48858- <input type="hidden" name="list_pk" value="{{ list.pk }}">
48859- <legend>Unsubscribe</legend>
48860- <div>
48861- <input type="checkbox" required="" name="im-sure" id="unsubscribe-im-sure">
48862- <label for="unsubscribe-im-sure">I am certain I want to unsubscribe.</label>
48863- </div>
48864- </fieldset>
48865- <input type="submit" name="subscribe" value="Unsubscribe">
48866- </form>
48867- </div>
48868- {% include "footer.html" %}
48869 diff --git a/web/src/templates/topics.html b/web/src/templates/topics.html
48870deleted file mode 100644
48871index ec5b8d3..0000000
48872--- a/web/src/templates/topics.html
48873+++ /dev/null
48874 @@ -1,13 +0,0 @@
48875- {% include "header.html" %}
48876- <div class="body">
48877- <p style="margin-block-end: 1rem;">Results for <bdi><em>{{ term }}</em></bdi></p>
48878- <div class="entry">
48879- <dl class="lists" aria-label="list of mailing lists">
48880- {% for list in results %}
48881- <dt aria-label="mailing list name"><a href="{{ list_path(list.id) }}">{{ list.id }}</a></dt>
48882- <dd><span aria-label="mailing list description"{% if not list.description %} class="no-description"{% endif %}>{{ list.description if list.description else "<p>no description</p>"|safe }}</span>{% if list.topics|length > 0 %}<span class="list-topics"><span>Topics:</span>&nbsp;{{ list.topics_html() }}</span>{% endif %}</dd>
48883- {% endfor %}
48884- </dl>
48885- </div>
48886- </div>
48887- {% include "footer.html" %}
48888 diff --git a/web/src/topics.rs b/web/src/topics.rs
48889deleted file mode 100644
48890index 13c2b9a..0000000
48891--- a/web/src/topics.rs
48892+++ /dev/null
48893 @@ -1,153 +0,0 @@
48894- /*
48895- * This file is part of mailpot
48896- *
48897- * Copyright 2020 - Manos Pitsidianakis
48898- *
48899- * This program is free software: you can redistribute it and/or modify
48900- * it under the terms of the GNU Affero General Public License as
48901- * published by the Free Software Foundation, either version 3 of the
48902- * License, or (at your option) any later version.
48903- *
48904- * This program is distributed in the hope that it will be useful,
48905- * but WITHOUT ANY WARRANTY; without even the implied warranty of
48906- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
48907- * GNU Affero General Public License for more details.
48908- *
48909- * You should have received a copy of the GNU Affero General Public License
48910- * along with this program. If not, see <https://www.gnu.org/licenses/>.
48911- */
48912-
48913- use super::*;
48914-
48915- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
48916- pub struct SearchTerm {
48917- query: Option<String>,
48918- }
48919-
48920- #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
48921- pub struct SearchResult {
48922- pk: i64,
48923- id: String,
48924- description: Option<String>,
48925- topics: Vec<String>,
48926- }
48927-
48928- impl std::fmt::Display for SearchResult {
48929- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
48930- write!(fmt, "{:?}", self)
48931- }
48932- }
48933-
48934- impl Object for SearchResult {
48935- fn kind(&self) -> minijinja::value::ObjectKind {
48936- minijinja::value::ObjectKind::Struct(self)
48937- }
48938-
48939- fn call_method(
48940- &self,
48941- _state: &minijinja::State,
48942- name: &str,
48943- _args: &[Value],
48944- ) -> std::result::Result<Value, Error> {
48945- match name {
48946- "topics_html" => crate::minijinja_utils::topics_common(&self.topics),
48947- _ => Err(Error::new(
48948- minijinja::ErrorKind::UnknownMethod,
48949- format!("object has no method named {name}"),
48950- )),
48951- }
48952- }
48953- }
48954-
48955- impl minijinja::value::StructObject for SearchResult {
48956- fn get_field(&self, name: &str) -> Option<Value> {
48957- match name {
48958- "pk" => Some(Value::from_serializable(&self.pk)),
48959- "id" => Some(Value::from_serializable(&self.id)),
48960- "description" => Some(
48961- self.description
48962- .clone()
48963- .map(Value::from_safe_string)
48964- .unwrap_or_else(|| Value::from_serializable(&self.description)),
48965- ),
48966- "topics" => Some(Value::from_serializable(&self.topics)),
48967- _ => None,
48968- }
48969- }
48970-
48971- fn static_fields(&self) -> Option<&'static [&'static str]> {
48972- Some(&["pk", "id", "description", "topics"][..])
48973- }
48974- }
48975- pub async fn list_topics(
48976- _: TopicsPath,
48977- mut session: WritableSession,
48978- Query(SearchTerm { query: term }): Query<SearchTerm>,
48979- auth: AuthContext,
48980- State(state): State<Arc<AppState>>,
48981- ) -> Result<Html<String>, ResponseError> {
48982- let db = Connection::open_db(state.conf.clone())?.trusted();
48983-
48984- let results: Vec<Value> = {
48985- if let Some(term) = term.as_ref() {
48986- let mut stmt = db.connection.prepare(
48987- "SELECT DISTINCT list.pk, list.id, list.description, list.topics FROM list, \
48988- json_each(list.topics) WHERE json_each.value IS ?;",
48989- )?;
48990- let iter = stmt.query_map([&term], |row| {
48991- let pk = row.get(0)?;
48992- let id = row.get(1)?;
48993- let description = row.get(2)?;
48994- let topics = mailpot::models::MailingList::topics_from_json_value(row.get(3)?)?;
48995- Ok(Value::from_object(SearchResult {
48996- pk,
48997- id,
48998- description,
48999- topics,
49000- }))
49001- })?;
49002- let mut ret = vec![];
49003- for el in iter {
49004- let el = el?;
49005- ret.push(el);
49006- }
49007- ret
49008- } else {
49009- db.lists()?
49010- .into_iter()
49011- .map(DbVal::into_inner)
49012- .map(|l| SearchResult {
49013- pk: l.pk,
49014- id: l.id,
49015- description: l.description,
49016- topics: l.topics,
49017- })
49018- .map(Value::from_object)
49019- .collect()
49020- }
49021- };
49022-
49023- let crumbs = vec![
49024- Crumb {
49025- label: "Home".into(),
49026- url: "/".into(),
49027- },
49028- Crumb {
49029- label: "Search for topics".into(),
49030- url: TopicsPath.to_crumb(),
49031- },
49032- ];
49033- let context = minijinja::context! {
49034- canonical_url => TopicsPath.to_crumb(),
49035- term,
49036- results,
49037- page_title => "Topic Search Results",
49038- description => "",
49039- current_user => auth.current_user,
49040- messages => session.drain_messages(),
49041- crumbs,
49042- };
49043- Ok(Html(
49044- TEMPLATES.get_template("topics.html")?.render(context)?,
49045- ))
49046- }
49047 diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs
49048deleted file mode 100644
49049index 6e0b3de..0000000
49050--- a/web/src/typed_paths.rs
49051+++ /dev/null
49052 @@ -1,610 +0,0 @@
49053- /*
49054- * This file is part of mailpot
49055- *
49056- * Copyright 2020 - Manos Pitsidianakis
49057- *
49058- * This program is free software: you can redistribute it and/or modify
49059- * it under the terms of the GNU Affero General Public License as
49060- * published by the Free Software Foundation, either version 3 of the
49061- * License, or (at your option) any later version.
49062- *
49063- * This program is distributed in the hope that it will be useful,
49064- * but WITHOUT ANY WARRANTY; without even the implied warranty of
49065- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
49066- * GNU Affero General Public License for more details.
49067- *
49068- * You should have received a copy of the GNU Affero General Public License
49069- * along with this program. If not, see <https://www.gnu.org/licenses/>.
49070- */
49071-
49072- pub use mailpot::PATH_SEGMENT;
49073- use percent_encoding::utf8_percent_encode;
49074-
49075- use super::*;
49076-
49077- pub trait IntoCrumb: TypedPath {
49078- fn to_crumb(&self) -> Cow<'static, str> {
49079- Cow::from(self.to_uri().to_string())
49080- }
49081- }
49082-
49083- impl<TP: TypedPath> IntoCrumb for TP {}
49084-
49085- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
49086- #[serde(untagged)]
49087- pub enum ListPathIdentifier {
49088- Pk(#[serde(deserialize_with = "parse_int")] i64),
49089- Id(String),
49090- }
49091-
49092- fn parse_int<'de, T, D>(de: D) -> Result<T, D::Error>
49093- where
49094- D: serde::Deserializer<'de>,
49095- T: std::str::FromStr,
49096- <T as std::str::FromStr>::Err: std::fmt::Display,
49097- {
49098- use serde::Deserialize;
49099- String::deserialize(de)?
49100- .parse()
49101- .map_err(serde::de::Error::custom)
49102- }
49103-
49104- impl From<i64> for ListPathIdentifier {
49105- fn from(val: i64) -> Self {
49106- Self::Pk(val)
49107- }
49108- }
49109-
49110- impl From<String> for ListPathIdentifier {
49111- fn from(val: String) -> Self {
49112- Self::Id(val)
49113- }
49114- }
49115-
49116- impl std::fmt::Display for ListPathIdentifier {
49117- #[allow(clippy::unnecessary_to_owned)]
49118- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49119- let id: Cow<'_, str> = match self {
49120- Self::Pk(id) => id.to_string().into(),
49121- Self::Id(id) => id.into(),
49122- };
49123- write!(f, "{}", utf8_percent_encode(&id, PATH_SEGMENT,))
49124- }
49125- }
49126-
49127- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49128- #[typed_path("/list/:id/")]
49129- pub struct ListPath(pub ListPathIdentifier);
49130-
49131- impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
49132- fn from(val: &DbVal<mailpot::models::MailingList>) -> Self {
49133- Self(ListPathIdentifier::Id(val.id.clone()))
49134- }
49135- }
49136-
49137- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49138- #[typed_path("/list/:id/posts/:msgid/")]
49139- pub struct ListPostPath(pub ListPathIdentifier, pub String);
49140-
49141- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49142- #[typed_path("/list/:id/posts/:msgid/raw/")]
49143- pub struct ListPostRawPath(pub ListPathIdentifier, pub String);
49144-
49145- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49146- #[typed_path("/list/:id/posts/:msgid/eml/")]
49147- pub struct ListPostEmlPath(pub ListPathIdentifier, pub String);
49148-
49149- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49150- #[typed_path("/list/:id/edit/")]
49151- pub struct ListEditPath(pub ListPathIdentifier);
49152-
49153- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49154- #[typed_path("/list/:id/edit/subscribers/")]
49155- pub struct ListEditSubscribersPath(pub ListPathIdentifier);
49156-
49157- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49158- #[typed_path("/list/:id/edit/candidates/")]
49159- pub struct ListEditCandidatesPath(pub ListPathIdentifier);
49160-
49161- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49162- #[typed_path("/settings/list/:id/")]
49163- pub struct ListSettingsPath(pub ListPathIdentifier);
49164-
49165- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49166- #[typed_path("/login/")]
49167- pub struct LoginPath;
49168-
49169- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49170- #[typed_path("/logout/")]
49171- pub struct LogoutPath;
49172-
49173- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49174- #[typed_path("/settings/")]
49175- pub struct SettingsPath;
49176-
49177- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49178- #[typed_path("/help/")]
49179- pub struct HelpPath;
49180-
49181- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
49182- #[typed_path("/topics/")]
49183- pub struct TopicsPath;
49184-
49185- macro_rules! unit_impl {
49186- ($ident:ident, $ty:expr) => {
49187- pub fn $ident(state: &minijinja::State) -> std::result::Result<Value, Error> {
49188- urlize(state, Value::from($ty.to_crumb().to_string()))
49189- }
49190- };
49191- }
49192-
49193- unit_impl!(login_path, LoginPath);
49194- unit_impl!(logout_path, LogoutPath);
49195- unit_impl!(settings_path, SettingsPath);
49196- unit_impl!(help_path, HelpPath);
49197-
49198- macro_rules! list_id_impl {
49199- ($ident:ident, $ty:tt) => {
49200- pub fn $ident(state: &minijinja::State, id: Value) -> std::result::Result<Value, Error> {
49201- urlize(
49202- state,
49203- if let Some(id) = id.as_str() {
49204- Value::from(
49205- $ty(ListPathIdentifier::Id(id.to_string()))
49206- .to_crumb()
49207- .to_string(),
49208- )
49209- } else {
49210- let pk = id.try_into()?;
49211- Value::from($ty(ListPathIdentifier::Pk(pk)).to_crumb().to_string())
49212- },
49213- )
49214- }
49215- };
49216- }
49217-
49218- list_id_impl!(list_path, ListPath);
49219- list_id_impl!(list_settings_path, ListSettingsPath);
49220- list_id_impl!(list_edit_path, ListEditPath);
49221- list_id_impl!(list_subscribers_path, ListEditSubscribersPath);
49222- list_id_impl!(list_candidates_path, ListEditCandidatesPath);
49223-
49224- macro_rules! list_post_impl {
49225- ($ident:ident, $ty:tt) => {
49226- pub fn $ident(
49227- state: &minijinja::State,
49228- id: Value,
49229- msg_id: Value,
49230- ) -> std::result::Result<Value, Error> {
49231- urlize(state, {
49232- let Some(msg_id) = msg_id
49233- .as_str()
49234- .map(|s| s.to_string().strip_carets_inplace())
49235- else {
49236- return Err(Error::new(
49237- minijinja::ErrorKind::UnknownMethod,
49238- "Second argument of list_post_path must be a string.",
49239- ));
49240- };
49241-
49242- if let Some(id) = id.as_str() {
49243- Value::from(
49244- $ty(ListPathIdentifier::Id(id.to_string()), msg_id)
49245- .to_crumb()
49246- .to_string(),
49247- )
49248- } else {
49249- let pk = id.try_into()?;
49250- Value::from(
49251- $ty(ListPathIdentifier::Pk(pk), msg_id)
49252- .to_crumb()
49253- .to_string(),
49254- )
49255- }
49256- })
49257- }
49258- };
49259- }
49260-
49261- list_post_impl!(list_post_path, ListPostPath);
49262- list_post_impl!(post_raw_path, ListPostRawPath);
49263- list_post_impl!(post_eml_path, ListPostEmlPath);
49264-
49265- pub mod tsr {
49266- use std::{borrow::Cow, convert::Infallible};
49267-
49268- use axum::{
49269- http::Request,
49270- response::{IntoResponse, Redirect, Response},
49271- routing::{any, MethodRouter},
49272- Router,
49273- };
49274- use axum_extra::routing::{RouterExt as ExtraRouterExt, SecondElementIs, TypedPath};
49275- use http::{uri::PathAndQuery, StatusCode, Uri};
49276- use tower_service::Service;
49277-
49278- /// Extension trait that adds additional methods to [`Router`].
49279- pub trait RouterExt<S, B>: ExtraRouterExt<S, B> {
49280- /// Add a typed `GET` route to the router.
49281- ///
49282- /// The path will be inferred from the first argument to the handler
49283- /// function which must implement [`TypedPath`].
49284- ///
49285- /// See [`TypedPath`] for more details and examples.
49286- fn typed_get<H, T, P>(self, handler: H) -> Self
49287- where
49288- H: axum::handler::Handler<T, S, B>,
49289- T: SecondElementIs<P> + 'static,
49290- P: TypedPath;
49291-
49292- /// Add a typed `DELETE` route to the router.
49293- ///
49294- /// The path will be inferred from the first argument to the handler
49295- /// function which must implement [`TypedPath`].
49296- ///
49297- /// See [`TypedPath`] for more details and examples.
49298- fn typed_delete<H, T, P>(self, handler: H) -> Self
49299- where
49300- H: axum::handler::Handler<T, S, B>,
49301- T: SecondElementIs<P> + 'static,
49302- P: TypedPath;
49303-
49304- /// Add a typed `HEAD` route to the router.
49305- ///
49306- /// The path will be inferred from the first argument to the handler
49307- /// function which must implement [`TypedPath`].
49308- ///
49309- /// See [`TypedPath`] for more details and examples.
49310- fn typed_head<H, T, P>(self, handler: H) -> Self
49311- where
49312- H: axum::handler::Handler<T, S, B>,
49313- T: SecondElementIs<P> + 'static,
49314- P: TypedPath;
49315-
49316- /// Add a typed `OPTIONS` route to the router.
49317- ///
49318- /// The path will be inferred from the first argument to the handler
49319- /// function which must implement [`TypedPath`].
49320- ///
49321- /// See [`TypedPath`] for more details and examples.
49322- fn typed_options<H, T, P>(self, handler: H) -> Self
49323- where
49324- H: axum::handler::Handler<T, S, B>,
49325- T: SecondElementIs<P> + 'static,
49326- P: TypedPath;
49327-
49328- /// Add a typed `PATCH` route to the router.
49329- ///
49330- /// The path will be inferred from the first argument to the handler
49331- /// function which must implement [`TypedPath`].
49332- ///
49333- /// See [`TypedPath`] for more details and examples.
49334- fn typed_patch<H, T, P>(self, handler: H) -> Self
49335- where
49336- H: axum::handler::Handler<T, S, B>,
49337- T: SecondElementIs<P> + 'static,
49338- P: TypedPath;
49339-
49340- /// Add a typed `POST` route to the router.
49341- ///
49342- /// The path will be inferred from the first argument to the handler
49343- /// function which must implement [`TypedPath`].
49344- ///
49345- /// See [`TypedPath`] for more details and examples.
49346- fn typed_post<H, T, P>(self, handler: H) -> Self
49347- where
49348- H: axum::handler::Handler<T, S, B>,
49349- T: SecondElementIs<P> + 'static,
49350- P: TypedPath;
49351-
49352- /// Add a typed `PUT` route to the router.
49353- ///
49354- /// The path will be inferred from the first argument to the handler
49355- /// function which must implement [`TypedPath`].
49356- ///
49357- /// See [`TypedPath`] for more details and examples.
49358- fn typed_put<H, T, P>(self, handler: H) -> Self
49359- where
49360- H: axum::handler::Handler<T, S, B>,
49361- T: SecondElementIs<P> + 'static,
49362- P: TypedPath;
49363-
49364- /// Add a typed `TRACE` route to the router.
49365- ///
49366- /// The path will be inferred from the first argument to the handler
49367- /// function which must implement [`TypedPath`].
49368- ///
49369- /// See [`TypedPath`] for more details and examples.
49370- fn typed_trace<H, T, P>(self, handler: H) -> Self
49371- where
49372- H: axum::handler::Handler<T, S, B>,
49373- T: SecondElementIs<P> + 'static,
49374- P: TypedPath;
49375-
49376- /// Add another route to the router with an additional "trailing slash
49377- /// redirect" route.
49378- ///
49379- /// If you add a route _without_ a trailing slash, such as `/foo`, this
49380- /// method will also add a route for `/foo/` that redirects to
49381- /// `/foo`.
49382- ///
49383- /// If you add a route _with_ a trailing slash, such as `/bar/`, this
49384- /// method will also add a route for `/bar` that redirects to
49385- /// `/bar/`.
49386- ///
49387- /// This is similar to what axum 0.5.x did by default, except this
49388- /// explicitly adds another route, so trying to add a `/foo/`
49389- /// route after calling `.route_with_tsr("/foo", /* ... */)`
49390- /// will result in a panic due to route overlap.
49391- ///
49392- /// # Example
49393- ///
49394- /// ```
49395- /// use axum::{routing::get, Router};
49396- /// use axum_extra::routing::RouterExt;
49397- ///
49398- /// let app = Router::new()
49399- /// // `/foo/` will redirect to `/foo`
49400- /// .route_with_tsr("/foo", get(|| async {}))
49401- /// // `/bar` will redirect to `/bar/`
49402- /// .route_with_tsr("/bar/", get(|| async {}));
49403- /// # let _: Router = app;
49404- /// ```
49405- fn route_with_tsr(self, path: &str, method_router: MethodRouter<S, B>) -> Self
49406- where
49407- Self: Sized;
49408-
49409- /// Add another route to the router with an additional "trailing slash
49410- /// redirect" route.
49411- ///
49412- /// This works like [`RouterExt::route_with_tsr`] but accepts any
49413- /// [`Service`].
49414- fn route_service_with_tsr<T>(self, path: &str, service: T) -> Self
49415- where
49416- T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
49417- T::Response: IntoResponse,
49418- T::Future: Send + 'static,
49419- Self: Sized;
49420- }
49421-
49422- impl<S, B> RouterExt<S, B> for Router<S, B>
49423- where
49424- B: axum::body::HttpBody + Send + 'static,
49425- S: Clone + Send + Sync + 'static,
49426- {
49427- fn typed_get<H, T, P>(mut self, handler: H) -> Self
49428- where
49429- H: axum::handler::Handler<T, S, B>,
49430- T: SecondElementIs<P> + 'static,
49431- P: TypedPath,
49432- {
49433- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49434- self = self.route(
49435- tsr_path.as_ref(),
49436- axum::routing::get(move |url| tsr_handler_into_async(url, tsr_handler)),
49437- );
49438- self = self.route(P::PATH, axum::routing::get(handler));
49439- self
49440- }
49441-
49442- fn typed_delete<H, T, P>(mut self, handler: H) -> Self
49443- where
49444- H: axum::handler::Handler<T, S, B>,
49445- T: SecondElementIs<P> + 'static,
49446- P: TypedPath,
49447- {
49448- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49449- self = self.route(
49450- tsr_path.as_ref(),
49451- axum::routing::delete(move |url| tsr_handler_into_async(url, tsr_handler)),
49452- );
49453- self = self.route(P::PATH, axum::routing::delete(handler));
49454- self
49455- }
49456-
49457- fn typed_head<H, T, P>(mut self, handler: H) -> Self
49458- where
49459- H: axum::handler::Handler<T, S, B>,
49460- T: SecondElementIs<P> + 'static,
49461- P: TypedPath,
49462- {
49463- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49464- self = self.route(
49465- tsr_path.as_ref(),
49466- axum::routing::head(move |url| tsr_handler_into_async(url, tsr_handler)),
49467- );
49468- self = self.route(P::PATH, axum::routing::head(handler));
49469- self
49470- }
49471-
49472- fn typed_options<H, T, P>(mut self, handler: H) -> Self
49473- where
49474- H: axum::handler::Handler<T, S, B>,
49475- T: SecondElementIs<P> + 'static,
49476- P: TypedPath,
49477- {
49478- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49479- self = self.route(
49480- tsr_path.as_ref(),
49481- axum::routing::options(move |url| tsr_handler_into_async(url, tsr_handler)),
49482- );
49483- self = self.route(P::PATH, axum::routing::options(handler));
49484- self
49485- }
49486-
49487- fn typed_patch<H, T, P>(mut self, handler: H) -> Self
49488- where
49489- H: axum::handler::Handler<T, S, B>,
49490- T: SecondElementIs<P> + 'static,
49491- P: TypedPath,
49492- {
49493- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49494- self = self.route(
49495- tsr_path.as_ref(),
49496- axum::routing::patch(move |url| tsr_handler_into_async(url, tsr_handler)),
49497- );
49498- self = self.route(P::PATH, axum::routing::patch(handler));
49499- self
49500- }
49501-
49502- fn typed_post<H, T, P>(mut self, handler: H) -> Self
49503- where
49504- H: axum::handler::Handler<T, S, B>,
49505- T: SecondElementIs<P> + 'static,
49506- P: TypedPath,
49507- {
49508- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49509- self = self.route(
49510- tsr_path.as_ref(),
49511- axum::routing::post(move |url| tsr_handler_into_async(url, tsr_handler)),
49512- );
49513- self = self.route(P::PATH, axum::routing::post(handler));
49514- self
49515- }
49516-
49517- fn typed_put<H, T, P>(mut self, handler: H) -> Self
49518- where
49519- H: axum::handler::Handler<T, S, B>,
49520- T: SecondElementIs<P> + 'static,
49521- P: TypedPath,
49522- {
49523- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49524- self = self.route(
49525- tsr_path.as_ref(),
49526- axum::routing::put(move |url| tsr_handler_into_async(url, tsr_handler)),
49527- );
49528- self = self.route(P::PATH, axum::routing::put(handler));
49529- self
49530- }
49531-
49532- fn typed_trace<H, T, P>(mut self, handler: H) -> Self
49533- where
49534- H: axum::handler::Handler<T, S, B>,
49535- T: SecondElementIs<P> + 'static,
49536- P: TypedPath,
49537- {
49538- let (tsr_path, tsr_handler) = tsr_redirect_route(P::PATH);
49539- self = self.route(
49540- tsr_path.as_ref(),
49541- axum::routing::trace(move |url| tsr_handler_into_async(url, tsr_handler)),
49542- );
49543- self = self.route(P::PATH, axum::routing::trace(handler));
49544- self
49545- }
49546-
49547- #[track_caller]
49548- fn route_with_tsr(mut self, path: &str, method_router: MethodRouter<S, B>) -> Self
49549- where
49550- Self: Sized,
49551- {
49552- validate_tsr_path(path);
49553- self = self.route(path, method_router);
49554- add_tsr_redirect_route(self, path)
49555- }
49556-
49557- #[track_caller]
49558- fn route_service_with_tsr<T>(mut self, path: &str, service: T) -> Self
49559- where
49560- T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
49561- T::Response: IntoResponse,
49562- T::Future: Send + 'static,
49563- Self: Sized,
49564- {
49565- validate_tsr_path(path);
49566- self = self.route_service(path, service);
49567- add_tsr_redirect_route(self, path)
49568- }
49569- }
49570-
49571- #[track_caller]
49572- fn validate_tsr_path(path: &str) {
49573- if path == "/" {
49574- panic!("Cannot add a trailing slash redirect route for `/`")
49575- }
49576- }
49577-
49578- #[inline]
49579- fn add_tsr_redirect_route<S, B>(router: Router<S, B>, path: &str) -> Router<S, B>
49580- where
49581- B: axum::body::HttpBody + Send + 'static,
49582- S: Clone + Send + Sync + 'static,
49583- {
49584- async fn redirect_handler(uri: Uri) -> Response {
49585- let new_uri = map_path(uri, |path| {
49586- path.strip_suffix('/')
49587- .map(Cow::Borrowed)
49588- .unwrap_or_else(|| Cow::Owned(format!("{path}/")))
49589- });
49590-
49591- new_uri.map_or_else(
49592- || StatusCode::BAD_REQUEST.into_response(),
49593- |new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
49594- )
49595- }
49596-
49597- if let Some(path_without_trailing_slash) = path.strip_suffix('/') {
49598- router.route(path_without_trailing_slash, any(redirect_handler))
49599- } else {
49600- router.route(&format!("{path}/"), any(redirect_handler))
49601- }
49602- }
49603-
49604- #[inline]
49605- fn tsr_redirect_route(path: &'_ str) -> (Cow<'_, str>, fn(Uri) -> Response) {
49606- fn redirect_handler(uri: Uri) -> Response {
49607- let new_uri = map_path(uri, |path| {
49608- path.strip_suffix('/')
49609- .map(Cow::Borrowed)
49610- .unwrap_or_else(|| Cow::Owned(format!("{path}/")))
49611- });
49612-
49613- new_uri.map_or_else(
49614- || StatusCode::BAD_REQUEST.into_response(),
49615- |new_uri| Redirect::permanent(&new_uri.to_string()).into_response(),
49616- )
49617- }
49618-
49619- path.strip_suffix('/').map_or_else(
49620- || {
49621- (
49622- Cow::Owned(format!("{path}/")),
49623- redirect_handler as fn(Uri) -> Response,
49624- )
49625- },
49626- |path_without_trailing_slash| {
49627- (
49628- Cow::Borrowed(path_without_trailing_slash),
49629- redirect_handler as fn(Uri) -> Response,
49630- )
49631- },
49632- )
49633- }
49634-
49635- #[inline]
49636- async fn tsr_handler_into_async(u: Uri, h: fn(Uri) -> Response) -> Response {
49637- h(u)
49638- }
49639-
49640- /// Map the path of a `Uri`.
49641- ///
49642- /// Returns `None` if the `Uri` cannot be put back together with the new
49643- /// path.
49644- fn map_path<F>(original_uri: Uri, f: F) -> Option<Uri>
49645- where
49646- F: FnOnce(&str) -> Cow<'_, str>,
49647- {
49648- let mut parts = original_uri.into_parts();
49649- let path_and_query = parts.path_and_query.as_ref()?;
49650-
49651- let new_path = f(path_and_query.path());
49652-
49653- let new_path_and_query = if let Some(query) = &path_and_query.query() {
49654- format!("{new_path}?{query}").parse::<PathAndQuery>().ok()?
49655- } else {
49656- new_path.parse::<PathAndQuery>().ok()?
49657- };
49658- parts.path_and_query = Some(new_path_and_query);
49659-
49660- Uri::from_parts(parts).ok()
49661- }
49662- }
49663 diff --git a/web/src/utils.rs b/web/src/utils.rs
49664deleted file mode 100644
49665index 60217ee..0000000
49666--- a/web/src/utils.rs
49667+++ /dev/null
49668 @@ -1,465 +0,0 @@
49669- /*
49670- * This file is part of mailpot
49671- *
49672- * Copyright 2020 - Manos Pitsidianakis
49673- *
49674- * This program is free software: you can redistribute it and/or modify
49675- * it under the terms of the GNU Affero General Public License as
49676- * published by the Free Software Foundation, either version 3 of the
49677- * License, or (at your option) any later version.
49678- *
49679- * This program is distributed in the hope that it will be useful,
49680- * but WITHOUT ANY WARRANTY; without even the implied warranty of
49681- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
49682- * GNU Affero General Public License for more details.
49683- *
49684- * You should have received a copy of the GNU Affero General Public License
49685- * along with this program. If not, see <https://www.gnu.org/licenses/>.
49686- */
49687-
49688- use super::*;
49689-
49690- /// Navigation crumbs, e.g.: Home > Page > Subpage
49691- ///
49692- /// # Example
49693- ///
49694- /// ```rust
49695- /// # use mailpot_web::utils::Crumb;
49696- /// let crumbs = vec![Crumb {
49697- /// label: "Home".into(),
49698- /// url: "/".into(),
49699- /// }];
49700- /// println!("{} {}", crumbs[0].label, crumbs[0].url);
49701- /// ```
49702- #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
49703- pub struct Crumb {
49704- pub label: Cow<'static, str>,
49705- #[serde(serialize_with = "to_safe_string")]
49706- pub url: Cow<'static, str>,
49707- }
49708-
49709- /// Message urgency level or info.
49710- #[derive(
49711- Debug, Default, Hash, Copy, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq,
49712- )]
49713- pub enum Level {
49714- Success,
49715- #[default]
49716- Info,
49717- Warning,
49718- Error,
49719- }
49720-
49721- /// UI message notifications.
49722- #[derive(Debug, Hash, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
49723- pub struct Message {
49724- pub message: Cow<'static, str>,
49725- #[serde(default)]
49726- pub level: Level,
49727- }
49728-
49729- impl Message {
49730- const MESSAGE_KEY: &'static str = "session-message";
49731- }
49732-
49733- /// Drain messages from session.
49734- ///
49735- /// # Example
49736- ///
49737- /// ```no_run
49738- /// # use mailpot_web::utils::{Message, Level, SessionMessages};
49739- /// struct Session(Vec<Message>);
49740- ///
49741- /// impl SessionMessages for Session {
49742- /// type Error = std::convert::Infallible;
49743- /// fn drain_messages(&mut self) -> Vec<Message> {
49744- /// std::mem::take(&mut self.0)
49745- /// }
49746- ///
49747- /// fn add_message(&mut self, m: Message) -> Result<(), std::convert::Infallible> {
49748- /// self.0.push(m);
49749- /// Ok(())
49750- /// }
49751- /// }
49752- /// let mut s = Session(vec![]);
49753- /// s.add_message(Message {
49754- /// message: "foo".into(),
49755- /// level: Level::default(),
49756- /// })
49757- /// .unwrap();
49758- /// s.add_message(Message {
49759- /// message: "bar".into(),
49760- /// level: Level::Error,
49761- /// })
49762- /// .unwrap();
49763- /// assert_eq!(
49764- /// s.drain_messages().as_slice(),
49765- /// [
49766- /// Message {
49767- /// message: "foo".into(),
49768- /// level: Level::default(),
49769- /// },
49770- /// Message {
49771- /// message: "bar".into(),
49772- /// level: Level::Error
49773- /// }
49774- /// ]
49775- /// .as_slice()
49776- /// );
49777- /// assert!(s.0.is_empty());
49778- /// ```
49779- pub trait SessionMessages {
49780- type Error;
49781-
49782- fn drain_messages(&mut self) -> Vec<Message>;
49783- fn add_message(&mut self, _: Message) -> Result<(), Self::Error>;
49784- }
49785-
49786- impl SessionMessages for WritableSession {
49787- type Error = ResponseError;
49788-
49789- fn drain_messages(&mut self) -> Vec<Message> {
49790- let ret = self.get(Message::MESSAGE_KEY).unwrap_or_default();
49791- self.remove(Message::MESSAGE_KEY);
49792- ret
49793- }
49794-
49795- #[allow(clippy::significant_drop_tightening)]
49796- fn add_message(&mut self, message: Message) -> Result<(), ResponseError> {
49797- let mut messages: Vec<Message> = self.get(Message::MESSAGE_KEY).unwrap_or_default();
49798- messages.push(message);
49799- self.insert(Message::MESSAGE_KEY, messages)?;
49800- Ok(())
49801- }
49802- }
49803-
49804- /// Deserialize a string integer into `i64`, because POST parameters are
49805- /// strings.
49806- #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Hash)]
49807- #[repr(transparent)]
49808- pub struct IntPOST(pub i64);
49809-
49810- impl serde::Serialize for IntPOST {
49811- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
49812- where
49813- S: serde::Serializer,
49814- {
49815- serializer.serialize_i64(self.0)
49816- }
49817- }
49818-
49819- impl<'de> serde::Deserialize<'de> for IntPOST {
49820- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
49821- where
49822- D: serde::Deserializer<'de>,
49823- {
49824- struct IntVisitor;
49825-
49826- impl<'de> serde::de::Visitor<'de> for IntVisitor {
49827- type Value = IntPOST;
49828-
49829- fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
49830- f.write_str("Int as a number or string")
49831- }
49832-
49833- fn visit_i64<E>(self, int: i64) -> Result<Self::Value, E>
49834- where
49835- E: serde::de::Error,
49836- {
49837- Ok(IntPOST(int))
49838- }
49839-
49840- fn visit_u64<E>(self, int: u64) -> Result<Self::Value, E>
49841- where
49842- E: serde::de::Error,
49843- {
49844- Ok(IntPOST(int.try_into().unwrap()))
49845- }
49846-
49847- fn visit_str<E>(self, int: &str) -> Result<Self::Value, E>
49848- where
49849- E: serde::de::Error,
49850- {
49851- int.parse().map(IntPOST).map_err(serde::de::Error::custom)
49852- }
49853- }
49854-
49855- deserializer.deserialize_any(IntVisitor)
49856- }
49857- }
49858-
49859- /// Deserialize a string integer into `bool`, because POST parameters are
49860- /// strings.
49861- #[derive(Clone, Copy, Default, Debug, PartialEq, Eq, PartialOrd, Hash)]
49862- #[repr(transparent)]
49863- pub struct BoolPOST(pub bool);
49864-
49865- impl serde::Serialize for BoolPOST {
49866- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
49867- where
49868- S: serde::Serializer,
49869- {
49870- serializer.serialize_bool(self.0)
49871- }
49872- }
49873-
49874- impl<'de> serde::Deserialize<'de> for BoolPOST {
49875- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
49876- where
49877- D: serde::Deserializer<'de>,
49878- {
49879- struct BoolVisitor;
49880-
49881- impl<'de> serde::de::Visitor<'de> for BoolVisitor {
49882- type Value = BoolPOST;
49883-
49884- fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
49885- f.write_str("Bool as a boolean or \"true\" \"false\"")
49886- }
49887-
49888- fn visit_bool<E>(self, val: bool) -> Result<Self::Value, E>
49889- where
49890- E: serde::de::Error,
49891- {
49892- Ok(BoolPOST(val))
49893- }
49894-
49895- fn visit_str<E>(self, val: &str) -> Result<Self::Value, E>
49896- where
49897- E: serde::de::Error,
49898- {
49899- val.parse().map(BoolPOST).map_err(serde::de::Error::custom)
49900- }
49901- }
49902-
49903- deserializer.deserialize_any(BoolVisitor)
49904- }
49905- }
49906-
49907- #[derive(Debug, Clone, serde::Deserialize)]
49908- pub struct Next {
49909- #[serde(default, deserialize_with = "empty_string_as_none")]
49910- pub next: Option<String>,
49911- }
49912-
49913- impl Next {
49914- #[inline]
49915- pub fn or_else(self, cl: impl FnOnce() -> String) -> Redirect {
49916- self.next
49917- .map_or_else(|| Redirect::to(&cl()), |next| Redirect::to(&next))
49918- }
49919- }
49920-
49921- /// Serde deserialization decorator to map empty Strings to None,
49922- fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
49923- where
49924- D: serde::Deserializer<'de>,
49925- T: std::str::FromStr,
49926- T::Err: std::fmt::Display,
49927- {
49928- use serde::Deserialize;
49929- let opt = Option::<String>::deserialize(de)?;
49930- match opt.as_deref() {
49931- None | Some("") => Ok(None),
49932- Some(s) => std::str::FromStr::from_str(s)
49933- .map_err(serde::de::Error::custom)
49934- .map(Some),
49935- }
49936- }
49937-
49938- /// Serialize string to [`minijinja::value::Value`] with
49939- /// [`minijinja::value::Value::from_safe_string`].
49940- pub fn to_safe_string<S>(s: impl AsRef<str>, ser: S) -> Result<S::Ok, S::Error>
49941- where
49942- S: serde::Serializer,
49943- {
49944- use serde::Serialize;
49945- let s = s.as_ref();
49946- Value::from_safe_string(s.to_string()).serialize(ser)
49947- }
49948-
49949- /// Serialize an optional string to [`minijinja::value::Value`] with
49950- /// [`minijinja::value::Value::from_safe_string`].
49951- pub fn to_safe_string_opt<S>(s: &Option<String>, ser: S) -> Result<S::Ok, S::Error>
49952- where
49953- S: serde::Serializer,
49954- {
49955- use serde::Serialize;
49956- s.as_ref()
49957- .map(|s| Value::from_safe_string(s.to_string()))
49958- .serialize(ser)
49959- }
49960-
49961- #[derive(Debug, Clone)]
49962- pub struct ThreadEntry {
49963- pub hash: melib::EnvelopeHash,
49964- pub depth: usize,
49965- pub thread_node: melib::ThreadNodeHash,
49966- pub thread: melib::ThreadHash,
49967- pub from: String,
49968- pub message_id: String,
49969- pub timestamp: u64,
49970- pub datetime: String,
49971- }
49972-
49973- pub fn thread(
49974- envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
49975- threads: &melib::Threads,
49976- root_env_hash: melib::EnvelopeHash,
49977- ) -> Vec<ThreadEntry> {
49978- let env_lock = envelopes.read().unwrap();
49979- let thread = threads.envelope_to_thread[&root_env_hash];
49980- let mut ret = vec![];
49981- for (depth, t) in threads.thread_iter(thread) {
49982- let hash = threads.thread_nodes[&t].message.unwrap();
49983- ret.push(ThreadEntry {
49984- hash,
49985- depth,
49986- thread_node: t,
49987- thread,
49988- message_id: env_lock[&hash].message_id().to_string(),
49989- from: env_lock[&hash].field_from_to_string(),
49990- datetime: env_lock[&hash].date_as_str().to_string(),
49991- timestamp: env_lock[&hash].timestamp,
49992- });
49993- }
49994- ret
49995- }
49996-
49997- pub fn thread_roots(
49998- envelopes: &Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>>,
49999- threads: &melib::Threads,
50000- ) -> Vec<(ThreadEntry, usize, u64)> {
50001- let items = threads.roots();
50002- let env_lock = envelopes.read().unwrap();
50003- let mut ret = vec![];
50004- 'items_for_loop: for thread in items {
50005- let mut iter_ptr = threads.thread_ref(thread).root();
50006- let thread_node = &threads.thread_nodes()[&iter_ptr];
50007- let root_env_hash = if let Some(h) = thread_node.message().or_else(|| {
50008- if thread_node.children().is_empty() {
50009- return None;
50010- }
50011- iter_ptr = thread_node.children()[0];
50012- while threads.thread_nodes()[&iter_ptr].message().is_none() {
50013- if threads.thread_nodes()[&iter_ptr].children().is_empty() {
50014- return None;
50015- }
50016- iter_ptr = threads.thread_nodes()[&iter_ptr].children()[0];
50017- }
50018- threads.thread_nodes()[&iter_ptr].message()
50019- }) {
50020- h
50021- } else {
50022- continue 'items_for_loop;
50023- };
50024- if !env_lock.contains_key(&root_env_hash) {
50025- panic!("key = {}", root_env_hash);
50026- }
50027- let envelope: &melib::Envelope = &env_lock[&root_env_hash];
50028- let tref = threads.thread_ref(thread);
50029- ret.push((
50030- ThreadEntry {
50031- hash: root_env_hash,
50032- depth: 0,
50033- thread_node: iter_ptr,
50034- thread,
50035- message_id: envelope.message_id().to_string(),
50036- from: envelope.field_from_to_string(),
50037- datetime: envelope.date_as_str().to_string(),
50038- timestamp: envelope.timestamp,
50039- },
50040- tref.len,
50041- tref.date,
50042- ));
50043- }
50044- // clippy: error: temporary with significant `Drop` can be early dropped
50045- drop(env_lock);
50046- ret.sort_by_key(|(_, _, key)| std::cmp::Reverse(*key));
50047- ret
50048- }
50049-
50050- #[cfg(test)]
50051- mod tests {
50052- use super::*;
50053-
50054- #[test]
50055- fn test_session() {
50056- struct Session(Vec<Message>);
50057-
50058- impl SessionMessages for Session {
50059- type Error = std::convert::Infallible;
50060- fn drain_messages(&mut self) -> Vec<Message> {
50061- std::mem::take(&mut self.0)
50062- }
50063-
50064- fn add_message(&mut self, m: Message) -> Result<(), std::convert::Infallible> {
50065- self.0.push(m);
50066- Ok(())
50067- }
50068- }
50069- let mut s = Session(vec![]);
50070- s.add_message(Message {
50071- message: "foo".into(),
50072- level: Level::default(),
50073- })
50074- .unwrap();
50075- s.add_message(Message {
50076- message: "bar".into(),
50077- level: Level::Error,
50078- })
50079- .unwrap();
50080- assert_eq!(
50081- s.drain_messages().as_slice(),
50082- [
50083- Message {
50084- message: "foo".into(),
50085- level: Level::default(),
50086- },
50087- Message {
50088- message: "bar".into(),
50089- level: Level::Error
50090- }
50091- ]
50092- .as_slice()
50093- );
50094- assert!(s.0.is_empty());
50095- }
50096-
50097- #[test]
50098- fn test_post_serde() {
50099- use mailpot::serde_json::{self, json};
50100- assert_eq!(
50101- IntPOST(5),
50102- serde_json::from_str::<IntPOST>("\"5\"").unwrap()
50103- );
50104- assert_eq!(IntPOST(5), serde_json::from_str::<IntPOST>("5").unwrap());
50105- assert_eq!(&json! { IntPOST(5) }.to_string(), "5");
50106-
50107- assert_eq!(
50108- BoolPOST(true),
50109- serde_json::from_str::<BoolPOST>("true").unwrap()
50110- );
50111- assert_eq!(
50112- BoolPOST(true),
50113- serde_json::from_str::<BoolPOST>("\"true\"").unwrap()
50114- );
50115- assert_eq!(&json! { BoolPOST(false) }.to_string(), "false");
50116- }
50117-
50118- #[test]
50119- fn test_next() {
50120- let next = Next {
50121- next: Some("foo".to_string()),
50122- };
50123- assert_eq!(
50124- format!("{:?}", Redirect::to("foo")),
50125- format!("{:?}", next.or_else(|| "bar".to_string()))
50126- );
50127- let next = Next { next: None };
50128- assert_eq!(
50129- format!("{:?}", Redirect::to("bar")),
50130- format!("{:?}", next.or_else(|| "bar".to_string()))
50131- );
50132- }
50133- }