+24245 -24244 +/-269 browse
1 | diff --git a/Cargo.toml b/Cargo.toml |
2 | index 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 |
25 | index 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 |
38 | deleted file mode 100644 |
39 | index 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 |
69 | deleted file mode 100644 |
70 | index 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 |
87 | deleted file mode 120000 |
88 | index 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 |
95 | deleted file mode 100644 |
96 | index 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 |
345 | deleted file mode 100644 |
346 | index 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 |
610 | deleted file mode 100644 |
611 | index 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 |
637 | deleted file mode 100644 |
638 | index 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 |
900 | deleted file mode 100644 |
901 | index 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 |
949 | deleted file mode 100644 |
950 | index 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 |
1262 | deleted file mode 100644 |
1263 | index 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 |
1276 | deleted file mode 100644 |
1277 | index 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 |
1299 | deleted file mode 100644 |
1300 | index 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 |
1317 | deleted file mode 100644 |
1318 | index 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">👤 <span class="from">{{ post.address }}</span> 📆 <span class="date">{{ post.datetime }}</span></span> |
1397 | - <span class="metadata">🪪 <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 |
1405 | deleted file mode 100644 |
1406 | index 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 |
1423 | deleted file mode 100644 |
1424 | index 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 |
1440 | deleted file mode 100644 |
1441 | index 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 |
1488 | deleted file mode 100644 |
1489 | index 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 |
1701 | deleted file mode 100644 |
1702 | index 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 |
1746 | deleted file mode 100644 |
1747 | index 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 |
1757 | deleted file mode 100644 |
1758 | index 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 |
2287 | deleted file mode 120000 |
2288 | index 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 |
2295 | deleted file mode 100644 |
2296 | index 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 |
2872 | deleted file mode 100644 |
2873 | index 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 |
3971 | deleted file mode 100644 |
3972 | index 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 |
4126 | deleted file mode 100644 |
4127 | index 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 |
4161 | deleted file mode 100644 |
4162 | index 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 |
4429 | deleted file mode 100644 |
4430 | index 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 |
4656 | deleted file mode 100644 |
4657 | index 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 |
4930 | deleted file mode 100644 |
4931 | index 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 |
5334 | deleted file mode 100644 |
5335 | index 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 |
5375 | deleted file mode 100644 |
5376 | index 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 |
5398 | deleted file mode 100644 |
5399 | index 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 |
5514 | deleted file mode 100644 |
5515 | index 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 |
5615 | deleted file mode 100644 |
5616 | index 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 |
5708 | deleted file mode 100644 |
5709 | index 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 |
5716 | deleted file mode 100644 |
5717 | index 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 |
5724 | deleted file mode 100644 |
5725 | index 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 |
5732 | deleted file mode 100644 |
5733 | index 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 |
5740 | deleted file mode 100644 |
5741 | index 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 |
5766 | deleted file mode 100644 |
5767 | index 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 |
5776 | deleted file mode 100644 |
5777 | index 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 |
5949 | deleted file mode 100644 |
5950 | index 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 |
5957 | deleted file mode 100644 |
5958 | index 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 |
5994 | deleted file mode 100644 |
5995 | index 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 |
6001 | deleted file mode 100644 |
6002 | index 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 |
6027 | deleted file mode 100644 |
6028 | index 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 |
6034 | deleted file mode 100644 |
6035 | index 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 |
6073 | deleted file mode 100644 |
6074 | index 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 |
6081 | deleted file mode 120000 |
6082 | index 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 |
6089 | deleted file mode 100644 |
6090 | index 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 |
6115 | deleted file mode 100644 |
6116 | index 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 |
6152 | deleted file mode 100644 |
6153 | index 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 |
6191 | deleted file mode 100644 |
6192 | index 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 |
6364 | deleted file mode 100644 |
6365 | index 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 |
7751 | deleted file mode 100644 |
7752 | index 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 |
7810 | deleted file mode 100644 |
7811 | index 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 |
8048 | deleted file mode 100644 |
8049 | index 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 |
8313 | deleted file mode 100644 |
8314 | index 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 |
8500 | deleted file mode 100644 |
8501 | index 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 |
8912 | deleted file mode 100644 |
8913 | index 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 |
8962 | deleted file mode 100644 |
8963 | index 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 |
9246 | deleted file mode 100644 |
9247 | index 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 |
9998 | deleted file mode 100644 |
9999 | index 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 |
10124 | deleted file mode 100644 |
10125 | index 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 |
10534 | deleted file mode 100644 |
10535 | index 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 |
11218 | deleted file mode 100644 |
11219 | index 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 |
12025 | deleted file mode 100644 |
12026 | index 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 |
12401 | deleted file mode 100644 |
12402 | index 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 |
13064 | deleted file mode 100644 |
13065 | index 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 |
13429 | deleted file mode 100644 |
13430 | index 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 |
13508 | deleted file mode 100644 |
13509 | index 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 |
14329 | deleted file mode 100644 |
14330 | index 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 |
14705 | deleted file mode 100644 |
14706 | index 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 |
14856 | deleted file mode 100644 |
14857 | index 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 |
14975 | deleted file mode 100644 |
14976 | index 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 |
15054 | deleted file mode 100644 |
15055 | index 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 |
15156 | deleted file mode 100644 |
15157 | index 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 |
15505 | deleted file mode 100644 |
15506 | index 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 |
15734 | deleted file mode 100644 |
15735 | index 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 |
16024 | deleted file mode 100644 |
16025 | index 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 |
16068 | deleted file mode 100644 |
16069 | index 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 |
16075 | deleted file mode 100644 |
16076 | index 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 |
16411 | deleted file mode 100644 |
16412 | index 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 |
16653 | deleted file mode 100644 |
16654 | index 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 |
16684 | deleted file mode 100755 |
16685 | index 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 |
16742 | new file mode 100644 |
16743 | index 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 |
16773 | new file mode 100644 |
16774 | index 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 |
16791 | new file mode 120000 |
16792 | index 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 |
16799 | new file mode 100644 |
16800 | index 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 |
17049 | new file mode 100644 |
17050 | index 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 |
17314 | new file mode 100644 |
17315 | index 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 |
17341 | new file mode 100644 |
17342 | index 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 |
17604 | new file mode 100644 |
17605 | index 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 |
17653 | new file mode 100644 |
17654 | index 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 |
17966 | new file mode 100644 |
17967 | index 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 |
17980 | new file mode 100644 |
17981 | index 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 |
18003 | new file mode 100644 |
18004 | index 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 |
18021 | new file mode 100644 |
18022 | index 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">👤 <span class="from">{{ post.address }}</span> 📆 <span class="date">{{ post.datetime }}</span></span> |
18101 | + <span class="metadata">🪪 <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 |
18109 | new file mode 100644 |
18110 | index 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 |
18127 | new file mode 100644 |
18128 | index 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 |
18144 | new file mode 100644 |
18145 | index 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 |
18192 | new file mode 100644 |
18193 | index 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 |
18405 | new file mode 100644 |
18406 | index 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 |
18450 | new file mode 100644 |
18451 | index 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 |
18461 | new file mode 100644 |
18462 | index 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 |
18991 | new file mode 120000 |
18992 | index 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 |
18999 | new file mode 100644 |
19000 | index 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 |
19576 | new file mode 100644 |
19577 | index 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 |
20675 | new file mode 100644 |
20676 | index 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 |
20830 | new file mode 100644 |
20831 | index 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 |
20865 | new file mode 100644 |
20866 | index 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 |
21133 | new file mode 100644 |
21134 | index 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 |
21360 | new file mode 100644 |
21361 | index 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 |
21634 | new file mode 100644 |
21635 | index 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 |
22038 | new file mode 100644 |
22039 | index 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 |
22046 | new file mode 100644 |
22047 | index 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 |
22101 | new file mode 100644 |
22102 | index 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 |
22109 | new file mode 100644 |
22110 | index 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 |
22127 | new file mode 100644 |
22128 | index 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 |
22139 | new file mode 100644 |
22140 | index 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 |
22154 | new file mode 120000 |
22155 | index 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 |
22162 | new file mode 100644 |
22163 | index 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 |
22266 | new file mode 100644 |
22267 | index 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 |
22323 | new file mode 100644 |
22324 | index 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 |
22361 | new file mode 100644 |
22362 | index 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 |
22784 | new file mode 100644 |
22785 | index 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 |
22791 | new file mode 100644 |
22792 | index 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 |
22858 | index 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 |
22871 | new file mode 100644 |
22872 | index 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 |
22878 | new file mode 100644 |
22879 | index 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 |
22943 | new file mode 100644 |
22944 | index 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 |
22969 | new file mode 100644 |
22970 | index 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 |
23080 | new file mode 120000 |
23081 | index 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 |
23088 | new file mode 100644 |
23089 | index 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 |
23938 | new file mode 100644 |
23939 | index 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 |
24187 | new file mode 100644 |
24188 | index 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 |
24238 | new file mode 100644 |
24239 | index 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 |
24477 | new file mode 100644 |
24478 | index 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 |
25304 | new file mode 100644 |
25305 | index 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 |
25864 | new file mode 100644 |
25865 | index 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 |
26763 | new file mode 100644 |
26764 | index 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 |
26789 | new file mode 100644 |
26790 | index 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 |
27206 | new file mode 100644 |
27207 | index 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----- changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange chang= -----END SSH SIGNATURE----- " 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 |
27227 | new file mode 100644 |
27228 | index 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 |
27276 | new file mode 100644 |
27277 | index 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 |
28374 | new file mode 100644 |
28375 | index 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 |
28386 | new file mode 100644 |
28387 | index 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 |
28427 | new file mode 100644 |
28428 | index 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 |
28453 | new file mode 100644 |
28454 | index 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 |
28470 | new file mode 100644 |
28471 | index 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> {{ 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 |
28489 | new file mode 100644 |
28490 | index 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 |
28651 | new file mode 100644 |
28652 | index 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 |
28696 | new file mode 100644 |
28697 | index 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> {{ 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> <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">👤 </span><span class="from" title="post author"><bdi>{{ post.address }}</bdi></span><span aria-hidden="true"> 📆 </span><span class="date" title="post date">{{ post.datetime }}</span></span> |
28807 | + {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">💓 </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 |
28816 | new file mode 100644 |
28817 | index 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 |
28835 | new file mode 100644 |
28836 | index 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 |
28898 | new file mode 100644 |
28899 | index 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 |
28951 | new file mode 100644 |
28952 | index 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 & 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 |
28968 | new file mode 100644 |
28969 | index 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">📆 <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 |
29057 | new file mode 100644 |
29058 | index 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 |
29124 | new file mode 100644 |
29125 | index 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> {{ 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 |
29143 | new file mode 100644 |
29144 | index 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 |
29302 | new file mode 100644 |
29303 | index 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 |
29918 | new file mode 100644 |
29919 | index 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 |
30389 | new file mode 100644 |
30390 | index 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 |
30430 | new file mode 100644 |
30431 | index 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 |
30453 | new file mode 100644 |
30454 | index 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 |
30569 | new file mode 100644 |
30570 | index 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 |
30670 | new file mode 100644 |
30671 | index 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 |
30763 | new file mode 100644 |
30764 | index 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 |
30771 | new file mode 100644 |
30772 | index 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 |
30779 | new file mode 100644 |
30780 | index 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 |
30787 | new file mode 100644 |
30788 | index 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 |
30795 | new file mode 100644 |
30796 | index 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 |
30821 | new file mode 100644 |
30822 | index 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 |
30831 | new file mode 100644 |
30832 | index 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 |
31004 | new file mode 100644 |
31005 | index 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 |
31012 | new file mode 100644 |
31013 | index 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 |
31049 | new file mode 100644 |
31050 | index 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 |
31056 | new file mode 100644 |
31057 | index 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 |
31082 | new file mode 100644 |
31083 | index 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 |
31089 | new file mode 100644 |
31090 | index 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 |
31128 | new file mode 100644 |
31129 | index 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 |
31136 | new file mode 120000 |
31137 | index 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 |
31144 | new file mode 100644 |
31145 | index 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 |
31170 | new file mode 100644 |
31171 | index 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 |
31207 | new file mode 100644 |
31208 | index 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 |
31246 | new file mode 100644 |
31247 | index 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 |
31419 | new file mode 100644 |
31420 | index 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 |
32806 | new file mode 100644 |
32807 | index 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 |
32865 | new file mode 100644 |
32866 | index 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 |
33103 | new file mode 100644 |
33104 | index 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 |
33368 | new file mode 100644 |
33369 | index 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 |
33555 | new file mode 100644 |
33556 | index 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 |
33967 | new file mode 100644 |
33968 | index 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 |
34017 | new file mode 100644 |
34018 | index 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 |
34301 | new file mode 100644 |
34302 | index 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 |
35053 | new file mode 100644 |
35054 | index 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 |
35179 | new file mode 100644 |
35180 | index 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 |
35589 | new file mode 100644 |
35590 | index 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 |
36273 | new file mode 100644 |
36274 | index 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 |
37080 | new file mode 100644 |
37081 | index 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 |
37456 | new file mode 100644 |
37457 | index 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 |
38119 | new file mode 100644 |
38120 | index 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 |
38484 | new file mode 100644 |
38485 | index 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 |
38563 | new file mode 100644 |
38564 | index 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 |
39384 | new file mode 100644 |
39385 | index 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 |
39760 | new file mode 100644 |
39761 | index 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 |
39911 | new file mode 100644 |
39912 | index 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 |
40030 | new file mode 100644 |
40031 | index 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 |
40109 | new file mode 100644 |
40110 | index 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 |
40211 | new file mode 100644 |
40212 | index 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 |
40560 | new file mode 100644 |
40561 | index 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 |
40789 | new file mode 100644 |
40790 | index 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 |
41079 | new file mode 100644 |
41080 | index 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 |
41123 | new file mode 100644 |
41124 | index 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 |
41130 | new file mode 100644 |
41131 | index 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 |
41466 | new file mode 100644 |
41467 | index 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 |
41708 | new file mode 100644 |
41709 | index 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 |
41739 | new file mode 100755 |
41740 | index 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 |
41797 | deleted file mode 100644 |
41798 | index 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 |
41805 | deleted file mode 100644 |
41806 | index 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 |
41860 | deleted file mode 100644 |
41861 | index 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 |
41868 | deleted file mode 100644 |
41869 | index 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 |
41886 | deleted file mode 100644 |
41887 | index 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 |
41898 | deleted file mode 100644 |
41899 | index 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 |
41913 | deleted file mode 120000 |
41914 | index 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 |
41921 | deleted file mode 100644 |
41922 | index 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 |
42025 | deleted file mode 100644 |
42026 | index 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 |
42082 | deleted file mode 100644 |
42083 | index 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 |
42120 | deleted file mode 100644 |
42121 | index 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 |
42543 | deleted file mode 100644 |
42544 | index 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 |
42550 | deleted file mode 100644 |
42551 | index 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 |
42617 | deleted file mode 100644 |
42618 | index 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 |
42624 | deleted file mode 100644 |
42625 | index 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 |
42689 | deleted file mode 100644 |
42690 | index 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 |
42715 | deleted file mode 100644 |
42716 | index 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 |
42826 | deleted file mode 120000 |
42827 | index 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 |
42834 | deleted file mode 100644 |
42835 | index 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 |
43684 | deleted file mode 100644 |
43685 | index 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 |
43933 | deleted file mode 100644 |
43934 | index 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 |
43984 | deleted file mode 100644 |
43985 | index 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 |
44223 | deleted file mode 100644 |
44224 | index 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 |
45050 | deleted file mode 100644 |
45051 | index 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 |
45610 | deleted file mode 100644 |
45611 | index 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 |
46509 | deleted file mode 100644 |
46510 | index 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 |
46535 | deleted file mode 100644 |
46536 | index 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 |
46952 | deleted file mode 100644 |
46953 | index 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----- changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange mechangemechangemechangemechangemechangemechangemechangemechangemechan gemechangemechangemechangemechangemechangemechangemechangemechangemech angemechangemechangemechangemechangemechangemechangemechangemechangeme changemechangemechangemechangemechangemechangemechangemechangemechange chang= -----END SSH SIGNATURE----- " 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 |
46973 | deleted file mode 100644 |
46974 | index 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 |
47022 | deleted file mode 100644 |
47023 | index 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 |
48120 | deleted file mode 100644 |
48121 | index 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 |
48132 | deleted file mode 100644 |
48133 | index 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 |
48173 | deleted file mode 100644 |
48174 | index 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 |
48199 | deleted file mode 100644 |
48200 | index 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 |
48216 | deleted file mode 100644 |
48217 | index 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> {{ 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 |
48235 | deleted file mode 100644 |
48236 | index 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 |
48397 | deleted file mode 100644 |
48398 | index 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 |
48442 | deleted file mode 100644 |
48443 | index 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> {{ 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> <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">👤 </span><span class="from" title="post author"><bdi>{{ post.address }}</bdi></span><span aria-hidden="true"> 📆 </span><span class="date" title="post date">{{ post.datetime }}</span></span> |
48553 | - {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">💓 </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 |
48562 | deleted file mode 100644 |
48563 | index 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 |
48581 | deleted file mode 100644 |
48582 | index 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 |
48644 | deleted file mode 100644 |
48645 | index 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 |
48697 | deleted file mode 100644 |
48698 | index 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 & 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 |
48714 | deleted file mode 100644 |
48715 | index 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">📆 <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 |
48803 | deleted file mode 100644 |
48804 | index 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 |
48870 | deleted file mode 100644 |
48871 | index 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> {{ 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 |
48889 | deleted file mode 100644 |
48890 | index 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 |
49048 | deleted file mode 100644 |
49049 | index 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 |
49664 | deleted file mode 100644 |
49665 | index 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 | - } |