+305 -115 +/-6 browse
1 | diff --git a/Cargo.lock b/Cargo.lock |
2 | index bf39166..dec3be9 100644 |
3 | --- a/Cargo.lock |
4 | +++ b/Cargo.lock |
5 | @@ -2191,6 +2191,7 @@ version = "0.1.1" |
6 | dependencies = [ |
7 | "anyhow", |
8 | "chrono", |
9 | + "data-encoding", |
10 | "jsonschema", |
11 | "log", |
12 | "mailpot-tests", |
13 | diff --git a/mailpot/Cargo.toml b/mailpot/Cargo.toml |
14 | index 2b82de9..e0bedfd 100644 |
15 | --- a/mailpot/Cargo.toml |
16 | +++ b/mailpot/Cargo.toml |
17 | @@ -16,6 +16,7 @@ doc-scrape-examples = true |
18 | [dependencies] |
19 | anyhow = "1.0.58" |
20 | chrono = { version = "^0.4", features = ["serde", ] } |
21 | + data-encoding = { version = "2.1.1" } |
22 | jsonschema = { version = "0.17", default-features = false } |
23 | log = "0.4" |
24 | melib = { version = "0.8.6", default-features = false, features = ["mbox", "smtp", "maildir"] } |
25 | diff --git a/mailpot/src/lib.rs b/mailpot/src/lib.rs |
26 | index e56a80a..8576c57 100644 |
27 | --- a/mailpot/src/lib.rs |
28 | +++ b/mailpot/src/lib.rs |
29 | @@ -257,3 +257,117 @@ const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}'); |
30 | |
31 | /// Set for percent encoding URL components. |
32 | pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%'); |
33 | + |
34 | + mod helpers { |
35 | + use std::borrow::Cow; |
36 | + |
37 | + use data_encoding::Encoding; |
38 | + |
39 | + fn base64_encoding() -> Encoding { |
40 | + let mut spec = data_encoding::BASE64_MIME.specification(); |
41 | + spec.ignore.clear(); |
42 | + spec.wrap.width = 0; |
43 | + spec.wrap.separator.clear(); |
44 | + spec.encoding().unwrap() |
45 | + } |
46 | + |
47 | + /// Ensure `value` is in appropriate representation to be a header value. |
48 | + pub fn encode_header(value: &'_ [u8]) -> Cow<'_, [u8]> { |
49 | + if value.iter().all(|&b| b.is_ascii_graphic() || b == b' ') { |
50 | + return Cow::Borrowed(value); |
51 | + } |
52 | + Cow::Owned(_encode_header(value)) |
53 | + } |
54 | + |
55 | + /// Same as [`encode_header`] but for owned bytes. |
56 | + pub fn encode_header_owned(value: Vec<u8>) -> Vec<u8> { |
57 | + if value.iter().all(|&b| b.is_ascii_graphic() || b == b' ') { |
58 | + return value; |
59 | + } |
60 | + _encode_header(&value) |
61 | + } |
62 | + |
63 | + fn _encode_header(value: &[u8]) -> Vec<u8> { |
64 | + let mut ret = Vec::with_capacity(value.len()); |
65 | + let base64_mime = base64_encoding(); |
66 | + let mut is_current_window_ascii = true; |
67 | + let mut current_window_start = 0; |
68 | + { |
69 | + for (idx, g) in value.iter().copied().enumerate() { |
70 | + match (g.is_ascii(), is_current_window_ascii) { |
71 | + (true, true) => { |
72 | + if g.is_ascii_graphic() || g == b' ' { |
73 | + ret.push(g); |
74 | + } else { |
75 | + current_window_start = idx; |
76 | + is_current_window_ascii = false; |
77 | + } |
78 | + } |
79 | + (true, false) => { |
80 | + /* If !g.is_whitespace() |
81 | + * |
82 | + * Whitespaces inside encoded tokens must be greedily taken, |
83 | + * instead of splitting each non-ascii word into separate encoded tokens. */ |
84 | + if g != b' ' && !g.is_ascii_control() { |
85 | + ret.extend_from_slice( |
86 | + format!( |
87 | + "=?UTF-8?B?{}?=", |
88 | + base64_mime.encode(&value[current_window_start..idx]).trim() |
89 | + ) |
90 | + .as_bytes(), |
91 | + ); |
92 | + if idx != value.len() - 1 |
93 | + && ((idx == 0) |
94 | + ^ (!value[idx - 1].is_ascii_control() |
95 | + && !value[idx - 1] != b' ')) |
96 | + { |
97 | + ret.push(b' '); |
98 | + } |
99 | + is_current_window_ascii = true; |
100 | + current_window_start = idx; |
101 | + ret.push(g); |
102 | + } |
103 | + } |
104 | + (false, true) => { |
105 | + current_window_start = idx; |
106 | + is_current_window_ascii = false; |
107 | + } |
108 | + /* RFC2047 recommends: |
109 | + * 'While there is no limit to the length of a multiple-line header field, |
110 | + * each line of a header field that contains one or more |
111 | + * 'encoded-word's is limited to 76 characters.' |
112 | + * This is a rough compliance. |
113 | + */ |
114 | + (false, false) if (((4 * (idx - current_window_start) / 3) + 3) & !3) > 33 => { |
115 | + ret.extend_from_slice( |
116 | + format!( |
117 | + "=?UTF-8?B?{}?=", |
118 | + base64_mime.encode(&value[current_window_start..idx]).trim() |
119 | + ) |
120 | + .as_bytes(), |
121 | + ); |
122 | + if idx != value.len() - 1 { |
123 | + ret.push(b' '); |
124 | + } |
125 | + current_window_start = idx; |
126 | + } |
127 | + (false, false) => {} |
128 | + } |
129 | + } |
130 | + } |
131 | + /* If the last part of the header value is encoded, it won't be pushed inside |
132 | + * the previous for block */ |
133 | + if !is_current_window_ascii { |
134 | + ret.extend_from_slice( |
135 | + format!( |
136 | + "=?UTF-8?B?{}?=", |
137 | + base64_mime.encode(&value[current_window_start..]).trim() |
138 | + ) |
139 | + .as_bytes(), |
140 | + ); |
141 | + } |
142 | + ret |
143 | + } |
144 | + } |
145 | + |
146 | + pub use helpers::*; |
147 | diff --git a/mailpot/src/message_filters.rs b/mailpot/src/message_filters.rs |
148 | index 553a471..547cb64 100644 |
149 | --- a/mailpot/src/message_filters.rs |
150 | +++ b/mailpot/src/message_filters.rs |
151 | @@ -166,21 +166,26 @@ impl PostFilter for AddListHeaders { |
152 | ) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> { |
153 | trace!("Running AddListHeaders filter"); |
154 | let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap(); |
155 | - let sender = format!("<{}>", ctx.list.address); |
156 | - headers.push((HeaderName::SENDER, sender.as_bytes())); |
157 | |
158 | - let list_id = Some(ctx.list.id_header()); |
159 | - let list_help = ctx.list.help_header(); |
160 | - let list_post = ctx.list.post_header(ctx.post_policy.as_deref()); |
161 | + let map_fn = |x| crate::encode_header_owned(String::into_bytes(x)); |
162 | + |
163 | + let sender = Some(format!("<{}>", ctx.list.address)).map(map_fn); |
164 | + |
165 | + let list_id = Some(map_fn(ctx.list.id_header())); |
166 | + let list_help = ctx.list.help_header().map(map_fn); |
167 | + let list_post = ctx.list.post_header(ctx.post_policy.as_deref()).map(map_fn); |
168 | let list_unsubscribe = ctx |
169 | .list |
170 | - .unsubscribe_header(ctx.subscription_policy.as_deref()); |
171 | + .unsubscribe_header(ctx.subscription_policy.as_deref()) |
172 | + .map(map_fn); |
173 | let list_subscribe = ctx |
174 | .list |
175 | - .subscribe_header(ctx.subscription_policy.as_deref()); |
176 | - let list_archive = ctx.list.archive_header(); |
177 | + .subscribe_header(ctx.subscription_policy.as_deref()) |
178 | + .map(map_fn); |
179 | + let list_archive = ctx.list.archive_header().map(map_fn); |
180 | |
181 | for (hdr, val) in [ |
182 | + (HeaderName::SENDER, &sender), |
183 | (HeaderName::LIST_ID, &list_id), |
184 | (HeaderName::LIST_HELP, &list_help), |
185 | (HeaderName::LIST_POST, &list_post), |
186 | @@ -189,7 +194,7 @@ impl PostFilter for AddListHeaders { |
187 | (HeaderName::LIST_ARCHIVE, &list_archive), |
188 | ] { |
189 | if let Some(val) = val { |
190 | - headers.push((hdr, val.as_bytes())); |
191 | + headers.push((hdr, val.as_slice())); |
192 | } |
193 | } |
194 | |
195 | @@ -239,11 +244,12 @@ impl PostFilter for AddSubjectTagPrefix { |
196 | let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap(); |
197 | let mut subject; |
198 | if let Some((_, subj_val)) = headers.iter_mut().find(|(k, _)| k == HeaderName::SUBJECT) { |
199 | - subject = format!("[{}] ", ctx.list.id).into_bytes(); |
200 | + subject = crate::encode_header_owned(format!("[{}] ", ctx.list.id).into_bytes()); |
201 | subject.extend(subj_val.iter().cloned()); |
202 | *subj_val = subject.as_slice(); |
203 | } else { |
204 | - subject = format!("[{}] (no subject)", ctx.list.id).into_bytes(); |
205 | + subject = |
206 | + crate::encode_header_owned(format!("[{}] (no subject)", ctx.list.id).into_bytes()); |
207 | headers.push((HeaderName::SUBJECT, subject.as_slice())); |
208 | } |
209 | |
210 | @@ -293,7 +299,7 @@ impl PostFilter for ArchivedAtLink { |
211 | |
212 | let env = minijinja::Environment::new(); |
213 | let message_id = post.message_id.to_string(); |
214 | - let header_val = env |
215 | + let header_val = crate::encode_header_owned(env |
216 | .render_named_str( |
217 | "ArchivedAtLinkSettings.template", |
218 | &template, |
219 | @@ -309,9 +315,9 @@ impl PostFilter for ArchivedAtLink { |
220 | ) |
221 | .map_err(|err| { |
222 | log::error!("ArchivedAtLink: {}", err); |
223 | - })?; |
224 | + })?.into_bytes()); |
225 | let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap(); |
226 | - headers.push((HeaderName::ARCHIVED_AT, header_val.as_bytes())); |
227 | + headers.push((HeaderName::ARCHIVED_AT, header_val.as_slice())); |
228 | |
229 | let mut new_vec = Vec::with_capacity( |
230 | headers |
231 | diff --git a/mailpot/tests/message_filters.rs b/mailpot/tests/message_filters.rs |
232 | new file mode 100644 |
233 | index 0000000..e5d3b0a |
234 | --- /dev/null |
235 | +++ b/mailpot/tests/message_filters.rs |
236 | @@ -0,0 +1,169 @@ |
237 | + /* |
238 | + * This file is part of mailpot |
239 | + * |
240 | + * Copyright 2020 - Manos Pitsidianakis |
241 | + * |
242 | + * This program is free software: you can redistribute it and/or modify |
243 | + * it under the terms of the GNU Affero General Public License as |
244 | + * published by the Free Software Foundation, either version 3 of the |
245 | + * License, or (at your option) any later version. |
246 | + * |
247 | + * This program is distributed in the hope that it will be useful, |
248 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
249 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
250 | + * GNU Affero General Public License for more details. |
251 | + * |
252 | + * You should have received a copy of the GNU Affero General Public License |
253 | + * along with this program. If not, see <https://www.gnu.org/licenses/>. |
254 | + */ |
255 | + |
256 | + use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail}; |
257 | + use mailpot_tests::init_stderr_logging; |
258 | + use serde_json::json; |
259 | + use tempfile::TempDir; |
260 | + |
261 | + #[test] |
262 | + fn test_post_filters() { |
263 | + init_stderr_logging(); |
264 | + let tmp_dir = TempDir::new().unwrap(); |
265 | + |
266 | + let mut post_policy = PostPolicy { |
267 | + pk: -1, |
268 | + list: -1, |
269 | + announce_only: false, |
270 | + subscription_only: false, |
271 | + approval_needed: false, |
272 | + open: true, |
273 | + custom: false, |
274 | + }; |
275 | + let db_path = tmp_dir.path().join("mpot.db"); |
276 | + let config = Configuration { |
277 | + send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()), |
278 | + db_path, |
279 | + data_path: tmp_dir.path().to_path_buf(), |
280 | + administrators: vec![], |
281 | + }; |
282 | + |
283 | + let db = Connection::open_or_create_db(config).unwrap().trusted(); |
284 | + let foo_chat = db |
285 | + .create_list(MailingList { |
286 | + pk: 0, |
287 | + name: "foobar chat".into(), |
288 | + id: "foo-chat".into(), |
289 | + address: "foo-chat@example.com".into(), |
290 | + description: None, |
291 | + topics: vec![], |
292 | + archive_url: None, |
293 | + }) |
294 | + .unwrap(); |
295 | + post_policy.list = foo_chat.pk(); |
296 | + db.add_subscription( |
297 | + foo_chat.pk(), |
298 | + ListSubscription { |
299 | + pk: -1, |
300 | + list: foo_chat.pk(), |
301 | + address: "user@example.com".into(), |
302 | + name: None, |
303 | + account: None, |
304 | + digest: false, |
305 | + enabled: true, |
306 | + verified: true, |
307 | + hide_address: false, |
308 | + receive_duplicates: true, |
309 | + receive_own_posts: true, |
310 | + receive_confirmation: false, |
311 | + }, |
312 | + ) |
313 | + .unwrap(); |
314 | + db.set_list_post_policy(post_policy).unwrap(); |
315 | + |
316 | + println!("Check that List subject prefix is inserted and can be optionally disabled…"); |
317 | + let post_bytes = b"From: Name <user@example.com> |
318 | + To: <foo-chat@example.com> |
319 | + Subject: This is a post |
320 | + Date: Thu, 29 Oct 2020 13:58:16 +0000 |
321 | + Message-ID: <abcdefgh@sator.example.com> |
322 | + Content-Language: en-US |
323 | + Content-Type: text/html |
324 | + Content-Transfer-Encoding: base64 |
325 | + MIME-Version: 1.0 |
326 | + |
327 | + PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k |
328 | + eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk |
329 | + Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k |
330 | + eT48L2h0bWw+ |
331 | + "; |
332 | + let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message"); |
333 | + db.post(&envelope, post_bytes, /* dry_run */ false).unwrap(); |
334 | + let q = db.queue(Queue::Out).unwrap(); |
335 | + assert_eq!(&q[0].subject, "[foo-chat] This is a post"); |
336 | + |
337 | + db.delete_from_queue(Queue::Out, vec![]).unwrap(); |
338 | + { |
339 | + let mut stmt = db |
340 | + .connection |
341 | + .prepare( |
342 | + "INSERT INTO list_settings_json(name, list, value) \ |
343 | + VALUES('AddSubjectTagPrefixSettings', ?, ?) RETURNING *;", |
344 | + ) |
345 | + .unwrap(); |
346 | + stmt.query_row( |
347 | + rusqlite::params![ |
348 | + &foo_chat.pk(), |
349 | + &json!({ |
350 | + "enabled": false |
351 | + }), |
352 | + ], |
353 | + |_| Ok(()), |
354 | + ) |
355 | + .unwrap(); |
356 | + } |
357 | + db.post(&envelope, post_bytes, /* dry_run */ false).unwrap(); |
358 | + let q = db.queue(Queue::Out).unwrap(); |
359 | + assert_eq!(&q[0].subject, "This is a post"); |
360 | + db.delete_from_queue(Queue::Out, vec![]).unwrap(); |
361 | + |
362 | + println!("Check that List headers are encoded with MIME when necessary…"); |
363 | + db.update_list(changesets::MailingListChangeset { |
364 | + pk: foo_chat.pk, |
365 | + description: Some(Some( |
366 | + "Why, I, in this weak piping time of peace,\nHave no delight to pass away the \ |
367 | + time,\nUnless to spy my shadow in the sun." |
368 | + .to_string(), |
369 | + )), |
370 | + ..Default::default() |
371 | + }) |
372 | + .unwrap(); |
373 | + db.post(&envelope, post_bytes, /* dry_run */ false).unwrap(); |
374 | + let q = db.queue(Queue::Out).unwrap(); |
375 | + let q_env = melib::Envelope::from_bytes(&q[0].message, None).expect("Could not parse message"); |
376 | + assert_eq!( |
377 | + &q_env.other_headers[melib::HeaderName::LIST_ID], |
378 | + "Why, I, in this weak piping time of peace,\nHave no delight to pass away the \ |
379 | + time,\nUnless to spy my shadow in the sun. <foo-chat.example.com>" |
380 | + ); |
381 | + db.delete_from_queue(Queue::Out, vec![]).unwrap(); |
382 | + |
383 | + db.update_list(changesets::MailingListChangeset { |
384 | + pk: foo_chat.pk, |
385 | + description: Some(Some( |
386 | + r#"<p>Discussion about mailpot, a mailing list manager software.</p> |
387 | + |
388 | + |
389 | + <ul> |
390 | + <li>Main git repository: <a href="https://git.meli-email.org/meli/mailpot">https://git.meli-email.org/meli/mailpot</a></li> |
391 | + <li>Mirror: <a href="https://github.com/meli/mailpot/">https://github.com/meli/mailpot/</a></li> |
392 | + </ul>"# |
393 | + .to_string(), |
394 | + )), |
395 | + ..Default::default() |
396 | + }) |
397 | + .unwrap(); |
398 | + db.post(&envelope, post_bytes, /* dry_run */ false).unwrap(); |
399 | + let q = db.queue(Queue::Out).unwrap(); |
400 | + let q_env = melib::Envelope::from_bytes(&q[0].message, None).expect("Could not parse message"); |
401 | + assert_eq!( |
402 | + &q_env.other_headers[melib::HeaderName::LIST_ID], |
403 | + "<p>Discussion about mailpot, a mailing list manager software.</p>\n\n\n<ul>\n<li>Main git repository: <a href=\"https://git.meli-email.org/meli/mailpot\">https://git.meli-email.org/meli/mailpot</a></li>\n<li>Mirror: <a href=\"https://github.com/meli/mailpot/\">https://github.com/meli/mailpot/</a></li>\n</ul> <foo-chat.example.com>" |
404 | + ); |
405 | + } |
406 | diff --git a/mailpot/tests/subscription.rs b/mailpot/tests/subscription.rs |
407 | index 1f5468c..c92081a 100644 |
408 | --- a/mailpot/tests/subscription.rs |
409 | +++ b/mailpot/tests/subscription.rs |
410 | @@ -19,7 +19,6 @@ |
411 | |
412 | use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail}; |
413 | use mailpot_tests::init_stderr_logging; |
414 | - use serde_json::json; |
415 | use tempfile::TempDir; |
416 | |
417 | #[test] |
418 | @@ -228,103 +227,3 @@ eT48L2h0bWw+ |
419 | } |
420 | } |
421 | } |
422 | - |
423 | - #[test] |
424 | - fn test_post_filters() { |
425 | - init_stderr_logging(); |
426 | - let tmp_dir = TempDir::new().unwrap(); |
427 | - |
428 | - let mut post_policy = PostPolicy { |
429 | - pk: -1, |
430 | - list: -1, |
431 | - announce_only: false, |
432 | - subscription_only: false, |
433 | - approval_needed: false, |
434 | - open: true, |
435 | - custom: false, |
436 | - }; |
437 | - let db_path = tmp_dir.path().join("mpot.db"); |
438 | - let config = Configuration { |
439 | - send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()), |
440 | - db_path, |
441 | - data_path: tmp_dir.path().to_path_buf(), |
442 | - administrators: vec![], |
443 | - }; |
444 | - |
445 | - let db = Connection::open_or_create_db(config).unwrap().trusted(); |
446 | - let foo_chat = db |
447 | - .create_list(MailingList { |
448 | - pk: 0, |
449 | - name: "foobar chat".into(), |
450 | - id: "foo-chat".into(), |
451 | - address: "foo-chat@example.com".into(), |
452 | - description: None, |
453 | - topics: vec![], |
454 | - archive_url: None, |
455 | - }) |
456 | - .unwrap(); |
457 | - post_policy.list = foo_chat.pk(); |
458 | - db.add_subscription( |
459 | - foo_chat.pk(), |
460 | - ListSubscription { |
461 | - pk: -1, |
462 | - list: foo_chat.pk(), |
463 | - address: "user@example.com".into(), |
464 | - name: None, |
465 | - account: None, |
466 | - digest: false, |
467 | - enabled: true, |
468 | - verified: true, |
469 | - hide_address: false, |
470 | - receive_duplicates: true, |
471 | - receive_own_posts: true, |
472 | - receive_confirmation: false, |
473 | - }, |
474 | - ) |
475 | - .unwrap(); |
476 | - db.set_list_post_policy(post_policy).unwrap(); |
477 | - |
478 | - let post_bytes = b"From: Name <user@example.com> |
479 | - To: <foo-chat@example.com> |
480 | - Subject: This is a post |
481 | - Date: Thu, 29 Oct 2020 13:58:16 +0000 |
482 | - Message-ID: <abcdefgh@sator.example.com> |
483 | - Content-Language: en-US |
484 | - Content-Type: text/html |
485 | - Content-Transfer-Encoding: base64 |
486 | - MIME-Version: 1.0 |
487 | - |
488 | - PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k |
489 | - eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk |
490 | - Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k |
491 | - eT48L2h0bWw+ |
492 | - "; |
493 | - let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message"); |
494 | - db.post(&envelope, post_bytes, /* dry_run */ false).unwrap(); |
495 | - let q = db.queue(Queue::Out).unwrap(); |
496 | - assert_eq!(&q[0].subject, "[foo-chat] This is a post"); |
497 | - |
498 | - db.delete_from_queue(Queue::Out, vec![]).unwrap(); |
499 | - { |
500 | - let mut stmt = db |
501 | - .connection |
502 | - .prepare( |
503 | - "INSERT INTO list_settings_json(name, list, value) \ |
504 | - VALUES('AddSubjectTagPrefixSettings', ?, ?) RETURNING *;", |
505 | - ) |
506 | - .unwrap(); |
507 | - stmt.query_row( |
508 | - rusqlite::params![ |
509 | - &foo_chat.pk(), |
510 | - &json!({ |
511 | - "enabled": false |
512 | - }), |
513 | - ], |
514 | - |_| Ok(()), |
515 | - ) |
516 | - .unwrap(); |
517 | - } |
518 | - db.post(&envelope, post_bytes, /* dry_run */ false).unwrap(); |
519 | - let q = db.queue(Queue::Out).unwrap(); |
520 | - assert_eq!(&q[0].subject, "This is a post"); |
521 | - } |