Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: 9b625e7c4cc09ab8bbaf71f59455f06fdae8d08e
Timestamp: Fri, 09 Jun 2023 13:34:24 +0000 (1 year ago)

+445 -103 +/-17 browse
core: implement message filter settings, use them in ArchivedAtLink filter
1diff --git a/core/build.rs b/core/build.rs
2index fd9dc55..f48cd5b 100644
3--- a/core/build.rs
4+++ b/core/build.rs
5 @@ -18,31 +18,30 @@
6 */
7
8 use std::{
9- fs::{metadata, OpenOptions},
10- io,
11+ fs::OpenOptions,
12 process::{Command, Stdio},
13 };
14
15- // Source: https://stackoverflow.com/a/64535181
16- fn is_output_file_outdated<P1, P2>(input: P1, output: P2) -> io::Result<bool>
17- where
18- P1: AsRef<Path>,
19- P2: AsRef<Path>,
20- {
21- let out_meta = metadata(output);
22- if let Ok(meta) = out_meta {
23- let output_mtime = meta.modified()?;
24-
25- // if input file is more recent than our output, we are outdated
26- let input_meta = metadata(input)?;
27- let input_mtime = input_meta.modified()?;
28-
29- Ok(input_mtime > output_mtime)
30- } else {
31- // output file not found, we are outdated
32- Ok(true)
33- }
34- }
35+ // // Source: https://stackoverflow.com/a/64535181
36+ // fn is_output_file_outdated<P1, P2>(input: P1, output: P2) -> io::Result<bool>
37+ // where
38+ // P1: AsRef<Path>,
39+ // P2: AsRef<Path>,
40+ // {
41+ // let out_meta = metadata(output);
42+ // if let Ok(meta) = out_meta {
43+ // let output_mtime = meta.modified()?;
44+ //
45+ // // if input file is more recent than our output, we are outdated
46+ // let input_meta = metadata(input)?;
47+ // let input_mtime = input_meta.modified()?;
48+ //
49+ // Ok(input_mtime > output_mtime)
50+ // } else {
51+ // // output file not found, we are outdated
52+ // Ok(true)
53+ // }
54+ // }
55
56 include!("make_migrations.rs");
57
58 diff --git a/core/make_migrations.rs b/core/make_migrations.rs
59index fa21a77..011970a 100644
60--- a/core/make_migrations.rs
61+++ b/core/make_migrations.rs
62 @@ -33,7 +33,6 @@ pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
63 let migrations_folder_path = migrations_path.as_ref();
64 let output_file_path = output_file.as_ref();
65
66- let mut regen = false;
67 let mut paths = vec![];
68 let mut undo_paths = vec![];
69 for entry in read_dir(migrations_folder_path).unwrap() {
70 @@ -42,9 +41,6 @@ pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
71 if path.is_dir() || path.extension().map(|os| os.to_str().unwrap()) != Some("sql") {
72 continue;
73 }
74- if is_output_file_outdated(&path, output_file_path).unwrap() {
75- regen = true;
76- }
77 if path
78 .file_name()
79 .unwrap()
80 @@ -58,56 +54,54 @@ pub fn make_migrations<M: AsRef<Path>, O: AsRef<Path>>(
81 }
82 }
83
84- if regen {
85- paths.sort();
86- undo_paths.sort();
87- let mut migr_rs = OpenOptions::new()
88- .write(true)
89- .create(true)
90- .truncate(true)
91- .open(output_file_path)
92- .unwrap();
93- migr_rs
94- .write_all(b"\n//(user_version, redo sql, undo sql\n&[")
95- .unwrap();
96- for (i, (p, u)) in paths.iter().zip(undo_paths.iter()).enumerate() {
97- // This should be a number string, padded with 2 zeros if it's less than 3
98- // digits. e.g. 001, \d{3}
99- let mut num = p.file_stem().unwrap().to_str().unwrap();
100- let is_data = num.ends_with(".data");
101- if is_data {
102- num = num.strip_suffix(".data").unwrap();
103- }
104+ paths.sort();
105+ undo_paths.sort();
106+ let mut migr_rs = OpenOptions::new()
107+ .write(true)
108+ .create(true)
109+ .truncate(true)
110+ .open(output_file_path)
111+ .unwrap();
112+ migr_rs
113+ .write_all(b"\n//(user_version, redo sql, undo sql\n&[")
114+ .unwrap();
115+ for (i, (p, u)) in paths.iter().zip(undo_paths.iter()).enumerate() {
116+ // This should be a number string, padded with 2 zeros if it's less than 3
117+ // digits. e.g. 001, \d{3}
118+ let mut num = p.file_stem().unwrap().to_str().unwrap();
119+ let is_data = num.ends_with(".data");
120+ if is_data {
121+ num = num.strip_suffix(".data").unwrap();
122+ }
123
124- if !u.file_name().unwrap().to_str().unwrap().starts_with(num) {
125- panic!("Undo file {u:?} should match with {p:?}");
126- }
127+ if !u.file_name().unwrap().to_str().unwrap().starts_with(num) {
128+ panic!("Undo file {u:?} should match with {p:?}");
129+ }
130
131- if num.parse::<u32>().is_err() {
132- panic!("Migration file {p:?} should start with a number");
133- }
134- 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());
135- migr_rs.write_all(b"(").unwrap();
136- migr_rs
137- .write_all(num.trim_start_matches('0').as_bytes())
138- .unwrap();
139- migr_rs.write_all(b",r###\"").unwrap();
140+ if num.parse::<u32>().is_err() {
141+ panic!("Migration file {p:?} should start with a number");
142+ }
143+ 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());
144+ migr_rs.write_all(b"(").unwrap();
145+ migr_rs
146+ .write_all(num.trim_start_matches('0').as_bytes())
147+ .unwrap();
148+ migr_rs.write_all(b",r###\"").unwrap();
149
150- let redo = std::fs::read_to_string(p).unwrap();
151- migr_rs.write_all(redo.trim().as_bytes()).unwrap();
152- migr_rs.write_all(b"\"###,r###\"").unwrap();
153- migr_rs
154- .write_all(std::fs::read_to_string(u).unwrap().trim().as_bytes())
155- .unwrap();
156- migr_rs.write_all(b"\"###),").unwrap();
157- if is_data {
158- schema_file.extend(b"\n\n-- ".iter());
159- schema_file.extend(num.as_bytes().iter());
160- schema_file.extend(b".data.sql\n\n".iter());
161- schema_file.extend(redo.into_bytes().into_iter());
162- }
163+ let redo = std::fs::read_to_string(p).unwrap();
164+ migr_rs.write_all(redo.trim().as_bytes()).unwrap();
165+ migr_rs.write_all(b"\"###,r###\"").unwrap();
166+ migr_rs
167+ .write_all(std::fs::read_to_string(u).unwrap().trim().as_bytes())
168+ .unwrap();
169+ migr_rs.write_all(b"\"###),").unwrap();
170+ if is_data {
171+ schema_file.extend(b"\n\n-- ".iter());
172+ schema_file.extend(num.as_bytes().iter());
173+ schema_file.extend(b".data.sql\n\n".iter());
174+ schema_file.extend(redo.into_bytes().into_iter());
175 }
176- migr_rs.write_all(b"]").unwrap();
177- migr_rs.flush().unwrap();
178 }
179+ migr_rs.write_all(b"]").unwrap();
180+ migr_rs.flush().unwrap();
181 }
182 diff --git a/core/migrations/006.data.sql b/core/migrations/006.data.sql
183new file mode 100644
184index 0000000..a5741e0
185--- /dev/null
186+++ b/core/migrations/006.data.sql
187 @@ -0,0 +1,20 @@
188+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
189+ "$schema": "http://json-schema.org/draft-07/schema",
190+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
191+ "$defs": {
192+ "AddSubjectTagPrefixSettings": {
193+ "title": "AddSubjectTagPrefixSettings",
194+ "description": "Settings for AddSubjectTagPrefix message filter",
195+ "type": "object",
196+ "properties": {
197+ "enabled": {
198+ "title": "If true, the list subject prefix is added to post subjects.",
199+ "type": "boolean"
200+ }
201+ },
202+ "required": [
203+ "enabled"
204+ ]
205+ }
206+ }
207+ }');
208 diff --git a/core/migrations/006.data.undo.sql b/core/migrations/006.data.undo.sql
209new file mode 100644
210index 0000000..a805e53
211--- /dev/null
212+++ b/core/migrations/006.data.undo.sql
213 @@ -0,0 +1 @@
214+ DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';
215 diff --git a/core/settings_json_schemas/addsubjecttagprefix.json b/core/settings_json_schemas/addsubjecttagprefix.json
216new file mode 100644
217index 0000000..4556b2b
218--- /dev/null
219+++ b/core/settings_json_schemas/addsubjecttagprefix.json
220 @@ -0,0 +1,20 @@
221+ {
222+ "$schema": "http://json-schema.org/draft-07/schema",
223+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
224+ "$defs": {
225+ "AddSubjectTagPrefixSettings": {
226+ "title": "AddSubjectTagPrefixSettings",
227+ "description": "Settings for AddSubjectTagPrefix message filter",
228+ "type": "object",
229+ "properties": {
230+ "enabled": {
231+ "title": "If true, the list subject prefix is added to post subjects.",
232+ "type": "boolean"
233+ }
234+ },
235+ "required": [
236+ "enabled"
237+ ]
238+ }
239+ }
240+ }
241 diff --git a/core/src/mail.rs b/core/src/mail.rs
242index c482c38..b33e715 100644
243--- a/core/src/mail.rs
244+++ b/core/src/mail.rs
245 @@ -21,8 +21,10 @@
246 //! [`PostFilter`](crate::message_filters::PostFilter), [`ListContext`],
247 //! [`MailJob`] and [`PostAction`].
248
249+ use std::collections::HashMap;
250+
251 use log::trace;
252- use melib::Address;
253+ use melib::{Address, MessageID};
254
255 use crate::{
256 models::{ListOwner, ListSubscription, MailingList, PostPolicy, SubscriptionPolicy},
257 @@ -65,6 +67,9 @@ pub struct ListContext<'list> {
258 /// The scheduled jobs added by each filter in a list's
259 /// [`PostFilter`](crate::message_filters::PostFilter) stack.
260 pub scheduled_jobs: Vec<MailJob>,
261+ /// Saved settings for message filters, which process a
262+ /// received e-mail before taking a final decision/action.
263+ pub filter_settings: HashMap<String, DbVal<serde_json::Value>>,
264 }
265
266 /// Post to be considered by the list's
267 @@ -79,12 +84,15 @@ pub struct PostEntry {
268 /// Final action set by each filter in a list's
269 /// [`PostFilter`](crate::message_filters::PostFilter) stack.
270 pub action: PostAction,
271+ /// Post's Message-ID
272+ pub message_id: MessageID,
273 }
274
275 impl core::fmt::Debug for PostEntry {
276 fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::fmt::Result {
277 fmt.debug_struct(stringify!(PostEntry))
278 .field("from", &self.from)
279+ .field("message_id", &self.message_id)
280 .field("bytes", &format_args!("{} bytes", self.bytes.len()))
281 .field("to", &self.to.as_slice())
282 .field("action", &self.action)
283 diff --git a/core/src/message_filters.rs b/core/src/message_filters.rs
284index 7da07c7..f30ce42 100644
285--- a/core/src/message_filters.rs
286+++ b/core/src/message_filters.rs
287 @@ -38,13 +38,16 @@
288 //!
289 //! so the processing stops at the first returned error.
290
291+ mod settings;
292 use log::trace;
293 use melib::Address;
294+ use percent_encoding::utf8_percent_encode;
295+ pub use settings::*;
296
297 use crate::{
298 mail::{ListContext, MailJob, PostAction, PostEntry},
299 models::{DbVal, MailingList},
300- Connection,
301+ Connection, StripCarets, PATH_SEGMENT,
302 };
303
304 impl Connection {
305 @@ -54,6 +57,7 @@ impl Connection {
306 Box::new(PostRightsCheck),
307 Box::new(FixCRLF),
308 Box::new(AddListHeaders),
309+ Box::new(ArchivedAtLink),
310 Box::new(AddSubjectTagPrefix),
311 Box::new(FinalizeRecipients),
312 ]
313 @@ -219,6 +223,18 @@ impl PostFilter for AddSubjectTagPrefix {
314 post: &'p mut PostEntry,
315 ctx: &'p mut ListContext<'list>,
316 ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
317+ if let Some(mut settings) = ctx.filter_settings.remove("AddSubjectTagPrefixSettings") {
318+ let map = settings.as_object_mut().unwrap();
319+ let enabled = serde_json::from_value::<bool>(map.remove("enabled").unwrap()).unwrap();
320+ if !enabled {
321+ trace!(
322+ "AddSubjectTagPrefix is disabled from settings found for list.pk = {} \
323+ skipping filter",
324+ ctx.list.pk
325+ );
326+ return Ok((post, ctx));
327+ }
328+ }
329 trace!("Running AddSubjectTagPrefix filter");
330 let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
331 let mut subject;
332 @@ -264,7 +280,58 @@ impl PostFilter for ArchivedAtLink {
333 post: &'p mut PostEntry,
334 ctx: &'p mut ListContext<'list>,
335 ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
336+ let Some(mut settings) = ctx.filter_settings.remove("ArchivedAtLinkSettings") else {
337+ trace!("No ArchivedAtLink settings found for list.pk = {} skipping filter", ctx.list.pk);
338+ return Ok((post, ctx));
339+ };
340 trace!("Running ArchivedAtLink filter");
341+
342+ let map = settings.as_object_mut().unwrap();
343+ let template = serde_json::from_value::<String>(map.remove("template").unwrap()).unwrap();
344+ let preserve_carets =
345+ serde_json::from_value::<bool>(map.remove("preserve_carets").unwrap()).unwrap();
346+
347+ let env = minijinja::Environment::new();
348+ let message_id = post.message_id.to_string();
349+ let header_val = env
350+ .render_named_str(
351+ "ArchivedAtLinkSettings.template",
352+ &template,
353+ &if preserve_carets {
354+ minijinja::context! {
355+ msg_id => utf8_percent_encode(message_id.as_str(), PATH_SEGMENT).to_string()
356+ }
357+ } else {
358+ minijinja::context! {
359+ msg_id => utf8_percent_encode(message_id.as_str().strip_carets(), PATH_SEGMENT).to_string()
360+ }
361+ },
362+ )
363+ .map_err(|err| {
364+ log::error!("ArchivedAtLink: {}", err);
365+ })?;
366+ let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
367+ headers.push((&b"Archived-At"[..], header_val.as_bytes()));
368+
369+ let mut new_vec = Vec::with_capacity(
370+ headers
371+ .iter()
372+ .map(|(h, v)| h.len() + v.len() + ": \r\n".len())
373+ .sum::<usize>()
374+ + "\r\n\r\n".len()
375+ + body.len(),
376+ );
377+ for (h, v) in headers {
378+ new_vec.extend_from_slice(h);
379+ new_vec.extend_from_slice(b": ");
380+ new_vec.extend_from_slice(v);
381+ new_vec.extend_from_slice(b"\r\n");
382+ }
383+ new_vec.extend_from_slice(b"\r\n\r\n");
384+ new_vec.extend_from_slice(body);
385+
386+ post.bytes = new_vec;
387+
388 Ok((post, ctx))
389 }
390 }
391 diff --git a/core/src/message_filters/settings.rs b/core/src/message_filters/settings.rs
392new file mode 100644
393index 0000000..b28be1b
394--- /dev/null
395+++ b/core/src/message_filters/settings.rs
396 @@ -0,0 +1,44 @@
397+ /*
398+ * This file is part of mailpot
399+ *
400+ * Copyright 2023 - Manos Pitsidianakis
401+ *
402+ * This program is free software: you can redistribute it and/or modify
403+ * it under the terms of the GNU Affero General Public License as
404+ * published by the Free Software Foundation, either version 3 of the
405+ * License, or (at your option) any later version.
406+ *
407+ * This program is distributed in the hope that it will be useful,
408+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
409+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
410+ * GNU Affero General Public License for more details.
411+ *
412+ * You should have received a copy of the GNU Affero General Public License
413+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
414+ */
415+
416+ //! Named templates, for generated e-mail like confirmations, alerts etc.
417+ //!
418+ //! Template database model: [`Template`].
419+
420+ use std::collections::HashMap;
421+
422+ use serde_json::Value;
423+
424+ use crate::{errors::*, Connection, DbVal};
425+
426+ impl Connection {
427+ /// Get json settings.
428+ pub fn get_settings(&self, list_pk: i64) -> Result<HashMap<String, DbVal<Value>>> {
429+ let mut stmt = self.connection.prepare(
430+ "SELECT pk, name, value FROM list_settings_json WHERE list = ? AND is_valid = 1;",
431+ )?;
432+ let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
433+ let pk: i64 = row.get("pk")?;
434+ let name: String = row.get("name")?;
435+ let value: Value = row.get("value")?;
436+ Ok((name, DbVal(value, pk)))
437+ })?;
438+ Ok(iter.collect::<std::result::Result<HashMap<String, DbVal<Value>>, rusqlite::Error>>()?)
439+ }
440+ }
441 diff --git a/core/src/migrations.rs.inc b/core/src/migrations.rs.inc
442index adfccf3..d7e5d8a 100644
443--- a/core/src/migrations.rs.inc
444+++ b/core/src/migrations.rs.inc
445 @@ -223,4 +223,23 @@ DROP TABLE list_settings_json;"###),(5,r###"INSERT OR REPLACE INTO settings_json
446 ]
447 }
448 }
449- }');"###,r###"DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';"###),]
450\ No newline at end of file
451+ }');"###,r###"DELETE FROM settings_json_schema WHERE id = 'ArchivedAtLinkSettings';"###),(6,r###"INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
452+ "$schema": "http://json-schema.org/draft-07/schema",
453+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
454+ "$defs": {
455+ "AddSubjectTagPrefixSettings": {
456+ "title": "AddSubjectTagPrefixSettings",
457+ "description": "Settings for AddSubjectTagPrefix message filter",
458+ "type": "object",
459+ "properties": {
460+ "enabled": {
461+ "title": "If true, the list subject prefix is added to post subjects.",
462+ "type": "boolean"
463+ }
464+ },
465+ "required": [
466+ "enabled"
467+ ]
468+ }
469+ }
470+ }');"###,r###"DELETE FROM settings_json_schema WHERE id = 'AddSubjectTagPrefixSettings';"###),]
471\ No newline at end of file
472 diff --git a/core/src/models.rs b/core/src/models.rs
473index f3c1e1d..daa4575 100644
474--- a/core/src/models.rs
475+++ b/core/src/models.rs
476 @@ -613,7 +613,7 @@ impl ListOwner {
477 }
478
479 /// A mailing list post entry.
480- #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
481+ #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
482 pub struct Post {
483 /// Database primary key.
484 pub pk: i64,
485 @@ -635,6 +635,22 @@ pub struct Post {
486 pub month_year: String,
487 }
488
489+ impl std::fmt::Debug for Post {
490+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
491+ fmt.debug_struct(stringify!(Post))
492+ .field("pk", &self.pk)
493+ .field("list", &self.list)
494+ .field("envelope_from", &self.envelope_from)
495+ .field("address", &self.address)
496+ .field("message_id", &self.message_id)
497+ .field("message", &String::from_utf8_lossy(&self.message))
498+ .field("timestamp", &self.timestamp)
499+ .field("datetime", &self.datetime)
500+ .field("month_year", &self.month_year)
501+ .finish()
502+ }
503+ }
504+
505 impl std::fmt::Display for Post {
506 fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
507 write!(fmt, "{:?}", self)
508 diff --git a/core/src/posts.rs b/core/src/posts.rs
509index 70eb7e1..fa00cfc 100644
510--- a/core/src/posts.rs
511+++ b/core/src/posts.rs
512 @@ -181,11 +181,13 @@ impl Connection {
513 post_policy: self.list_post_policy(list.pk)?,
514 subscription_policy: self.list_subscription_policy(list.pk)?,
515 list_owners: &owners,
516- list: &mut list,
517 subscriptions: &subscriptions,
518 scheduled_jobs: vec![],
519+ filter_settings: self.get_settings(list.pk)?,
520+ list: &mut list,
521 };
522 let mut post = PostEntry {
523+ message_id: env.message_id().clone(),
524 from: env.from()[0].clone(),
525 bytes: raw.to_vec(),
526 to: env.to().to_vec(),
527 diff --git a/core/src/schema.sql b/core/src/schema.sql
528index a983117..713d615 100644
529--- a/core/src/schema.sql
530+++ b/core/src/schema.sql
531 @@ -554,3 +554,62 @@ FOR EACH ROW
532 BEGIN
533 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;
534 END;
535+
536+
537+ -- 005.data.sql
538+
539+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('ArchivedAtLinkSettings', '{
540+ "$schema": "http://json-schema.org/draft-07/schema",
541+ "$ref": "#/$defs/ArchivedAtLinkSettings",
542+ "$defs": {
543+ "ArchivedAtLinkSettings": {
544+ "title": "ArchivedAtLinkSettings",
545+ "description": "Settings for ArchivedAtLink message filter",
546+ "type": "object",
547+ "properties": {
548+ "template": {
549+ "title": "Jinja template for header value",
550+ "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 ",
551+ "examples": [
552+ "https://www.example.com/{{msg_id}}",
553+ "https://www.example.com/{{msg_id}}.html"
554+ ],
555+ "type": "string",
556+ "pattern": ".+[{][{]msg_id[}][}].*"
557+ },
558+ "preserve_carets": {
559+ "title": "Preserve carets of `Message-ID` in generated value",
560+ "type": "boolean",
561+ "default": false
562+ }
563+ },
564+ "required": [
565+ "template"
566+ ]
567+ }
568+ }
569+ }');
570+
571+
572+ -- 006.data.sql
573+
574+ INSERT OR REPLACE INTO settings_json_schema(id, value) VALUES('AddSubjectTagPrefixSettings', '{
575+ "$schema": "http://json-schema.org/draft-07/schema",
576+ "$ref": "#/$defs/AddSubjectTagPrefixSettings",
577+ "$defs": {
578+ "AddSubjectTagPrefixSettings": {
579+ "title": "AddSubjectTagPrefixSettings",
580+ "description": "Settings for AddSubjectTagPrefix message filter",
581+ "type": "object",
582+ "properties": {
583+ "enabled": {
584+ "title": "If true, the list subject prefix is added to post subjects.",
585+ "type": "boolean"
586+ }
587+ },
588+ "required": [
589+ "enabled"
590+ ]
591+ }
592+ }
593+ }');
594 diff --git a/core/tests/migrations.rs b/core/tests/migrations.rs
595index 0c8fa9a..25fa17e 100644
596--- a/core/tests/migrations.rs
597+++ b/core/tests/migrations.rs
598 @@ -17,32 +17,12 @@
599 * along with this program. If not, see <https://www.gnu.org/licenses/>.
600 */
601
602- use std::fs::{metadata, File, OpenOptions};
603+ use std::fs::{File, OpenOptions};
604
605 use mailpot::{Configuration, Connection, SendMail};
606 use mailpot_tests::init_stderr_logging;
607 use tempfile::TempDir;
608
609- // Source: https://stackoverflow.com/a/64535181
610- fn is_output_file_outdated<P1, P2>(input: P1, output: P2) -> std::io::Result<bool>
611- where
612- P1: AsRef<Path>,
613- P2: AsRef<Path>,
614- {
615- let out_meta = metadata(output);
616- if let Ok(meta) = out_meta {
617- let output_mtime = meta.modified()?;
618-
619- // if input file is more recent than our output, we are outdated
620- let input_meta = metadata(input)?;
621- let input_mtime = input_meta.modified()?;
622-
623- Ok(input_mtime > output_mtime)
624- } else {
625- // output file not found, we are outdated
626- Ok(true)
627- }
628- }
629 include!("../make_migrations.rs");
630
631 #[test]
632 diff --git a/core/tests/settings_json.rs b/core/tests/settings_json.rs
633index e1600b0..ee79842 100644
634--- a/core/tests/settings_json.rs
635+++ b/core/tests/settings_json.rs
636 @@ -60,7 +60,8 @@ fn test_settings_json() {
637 rusqlite::params![
638 &list.pk(),
639 &json!({
640- "template": "https://www.example.com/{msg-id}.html"
641+ "template": "https://www.example.com/{{msg_id}}.html",
642+ "preserve_carets": false
643 }),
644 ],
645 |row| {
646 @@ -103,7 +104,7 @@ fn test_settings_json() {
647 rusqlite::params![
648 &list.pk(),
649 &json!({
650- "template": "https://www.example.com/msg-id}.html"
651+ "template": "https://www.example.com/msg-id}.html" // should be msg_id
652 }),
653 ],
654 |row| {
655 @@ -132,7 +133,7 @@ fn test_settings_json() {
656 &stmt
657 .query_row(
658 rusqlite::params![&json!({
659- "template": "https://www.example.com/msg-id}.html"
660+ "template": "https://www.example.com/msg-id}.html" // should be msg_id
661 }),],
662 |row| {
663 let pk: i64 = row.get("pk")?;
664 diff --git a/core/tests/subscription.rs b/core/tests/subscription.rs
665index c92081a..1f5468c 100644
666--- a/core/tests/subscription.rs
667+++ b/core/tests/subscription.rs
668 @@ -19,6 +19,7 @@
669
670 use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
671 use mailpot_tests::init_stderr_logging;
672+ use serde_json::json;
673 use tempfile::TempDir;
674
675 #[test]
676 @@ -227,3 +228,103 @@ eT48L2h0bWw+
677 }
678 }
679 }
680+
681+ #[test]
682+ fn test_post_filters() {
683+ init_stderr_logging();
684+ let tmp_dir = TempDir::new().unwrap();
685+
686+ let mut post_policy = PostPolicy {
687+ pk: -1,
688+ list: -1,
689+ announce_only: false,
690+ subscription_only: false,
691+ approval_needed: false,
692+ open: true,
693+ custom: false,
694+ };
695+ let db_path = tmp_dir.path().join("mpot.db");
696+ let config = Configuration {
697+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
698+ db_path,
699+ data_path: tmp_dir.path().to_path_buf(),
700+ administrators: vec![],
701+ };
702+
703+ let db = Connection::open_or_create_db(config).unwrap().trusted();
704+ let foo_chat = db
705+ .create_list(MailingList {
706+ pk: 0,
707+ name: "foobar chat".into(),
708+ id: "foo-chat".into(),
709+ address: "foo-chat@example.com".into(),
710+ description: None,
711+ topics: vec![],
712+ archive_url: None,
713+ })
714+ .unwrap();
715+ post_policy.list = foo_chat.pk();
716+ db.add_subscription(
717+ foo_chat.pk(),
718+ ListSubscription {
719+ pk: -1,
720+ list: foo_chat.pk(),
721+ address: "user@example.com".into(),
722+ name: None,
723+ account: None,
724+ digest: false,
725+ enabled: true,
726+ verified: true,
727+ hide_address: false,
728+ receive_duplicates: true,
729+ receive_own_posts: true,
730+ receive_confirmation: false,
731+ },
732+ )
733+ .unwrap();
734+ db.set_list_post_policy(post_policy).unwrap();
735+
736+ let post_bytes = b"From: Name <user@example.com>
737+ To: <foo-chat@example.com>
738+ Subject: This is a post
739+ Date: Thu, 29 Oct 2020 13:58:16 +0000
740+ Message-ID: <abcdefgh@sator.example.com>
741+ Content-Language: en-US
742+ Content-Type: text/html
743+ Content-Transfer-Encoding: base64
744+ MIME-Version: 1.0
745+
746+ PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
747+ eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
748+ Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
749+ eT48L2h0bWw+
750+ ";
751+ let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
752+ db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
753+ let q = db.queue(Queue::Out).unwrap();
754+ assert_eq!(&q[0].subject, "[foo-chat] This is a post");
755+
756+ db.delete_from_queue(Queue::Out, vec![]).unwrap();
757+ {
758+ let mut stmt = db
759+ .connection
760+ .prepare(
761+ "INSERT INTO list_settings_json(name, list, value) \
762+ VALUES('AddSubjectTagPrefixSettings', ?, ?) RETURNING *;",
763+ )
764+ .unwrap();
765+ stmt.query_row(
766+ rusqlite::params![
767+ &foo_chat.pk(),
768+ &json!({
769+ "enabled": false
770+ }),
771+ ],
772+ |_| Ok(()),
773+ )
774+ .unwrap();
775+ }
776+ db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
777+ let q = db.queue(Queue::Out).unwrap();
778+ assert_eq!(&q[0].subject, "This is a post");
779+ }
780 diff --git a/web/src/main.rs b/web/src/main.rs
781index 6cdb656..45520a4 100644
782--- a/web/src/main.rs
783+++ b/web/src/main.rs
784 @@ -359,7 +359,7 @@ mod tests {
785 assert_eq!(
786 res.headers().get(http::header::CONTENT_DISPOSITION),
787 Some(&http::HeaderValue::from_static(
788- "attachment; filename=\"<abcdefgh@sator.example.com>.eml\""
789+ "attachment; filename=\"abcdefgh@sator.example.com.eml\""
790 )),
791 );
792 }
793 diff --git a/web/src/templates/css.html b/web/src/templates/css.html
794index af92ddc..f3bd033 100644
795--- a/web/src/templates/css.html
796+++ b/web/src/templates/css.html
797 @@ -1050,4 +1050,15 @@
798 text-decoration: none;
799 color: inherit;
800 }
801+
802+ blockquote {
803+ margin-inline: 0 var(--gap);
804+ padding-inline: var(--gap) 0;
805+ margin-block: var(--gap);
806+ font-size: 1.1em;
807+ line-height: var(--rhythm);
808+ font-style: italic;
809+ border-inline-start: 1px solid var(--graphical-fg);
810+ color: var(--muted-fg);
811+ }
812 </style>