Skip to content

Commit

Permalink
mailpot: make sure inserted headers are properly encoded
Browse files Browse the repository at this point in the history
Closes #14

Link: <https://git.meli-email.org/meli/mailpot/issues/14>
Signed-off-by: Manos Pitsidianakis <[email protected]>
  • Loading branch information
epilys committed Jun 8, 2024
1 parent b9a22aa commit f425cf0
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 115 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions mailpot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ doc-scrape-examples = true
[dependencies]
anyhow = "1.0.58"
chrono = { version = "^0.4", features = ["serde", ] }
data-encoding = { version = "2.1.1" }
jsonschema = { version = "0.17", default-features = false }
log = "0.4"
melib = { version = "0.8.6", default-features = false, features = ["mbox", "smtp", "maildir"] }
Expand Down
114 changes: 114 additions & 0 deletions mailpot/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,117 @@ const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');

/// Set for percent encoding URL components.
pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%');

mod helpers {
use std::borrow::Cow;

use data_encoding::Encoding;

fn base64_encoding() -> Encoding {
let mut spec = data_encoding::BASE64_MIME.specification();
spec.ignore.clear();
spec.wrap.width = 0;
spec.wrap.separator.clear();
spec.encoding().unwrap()
}

/// Ensure `value` is in appropriate representation to be a header value.
pub fn encode_header(value: &'_ [u8]) -> Cow<'_, [u8]> {
if value.iter().all(|&b| b.is_ascii_graphic() || b == b' ') {
return Cow::Borrowed(value);
}
Cow::Owned(_encode_header(value))
}

/// Same as [`encode_header`] but for owned bytes.
pub fn encode_header_owned(value: Vec<u8>) -> Vec<u8> {
if value.iter().all(|&b| b.is_ascii_graphic() || b == b' ') {
return value;
}
_encode_header(&value)
}

fn _encode_header(value: &[u8]) -> Vec<u8> {
let mut ret = Vec::with_capacity(value.len());
let base64_mime = base64_encoding();
let mut is_current_window_ascii = true;
let mut current_window_start = 0;
{
for (idx, g) in value.iter().copied().enumerate() {
match (g.is_ascii(), is_current_window_ascii) {
(true, true) => {
if g.is_ascii_graphic() || g == b' ' {
ret.push(g);
} else {
current_window_start = idx;
is_current_window_ascii = false;
}
}
(true, false) => {
/* If !g.is_whitespace()
*
* Whitespaces inside encoded tokens must be greedily taken,
* instead of splitting each non-ascii word into separate encoded tokens. */
if g != b' ' && !g.is_ascii_control() {
ret.extend_from_slice(
format!(
"=?UTF-8?B?{}?=",
base64_mime.encode(&value[current_window_start..idx]).trim()
)
.as_bytes(),
);
if idx != value.len() - 1
&& ((idx == 0)
^ (!value[idx - 1].is_ascii_control()
&& !value[idx - 1] != b' '))
{
ret.push(b' ');
}
is_current_window_ascii = true;
current_window_start = idx;
ret.push(g);
}
}
(false, true) => {
current_window_start = idx;
is_current_window_ascii = false;
}
/* RFC2047 recommends:
* 'While there is no limit to the length of a multiple-line header field,
* each line of a header field that contains one or more
* 'encoded-word's is limited to 76 characters.'
* This is a rough compliance.
*/
(false, false) if (((4 * (idx - current_window_start) / 3) + 3) & !3) > 33 => {
ret.extend_from_slice(
format!(
"=?UTF-8?B?{}?=",
base64_mime.encode(&value[current_window_start..idx]).trim()
)
.as_bytes(),
);
if idx != value.len() - 1 {
ret.push(b' ');
}
current_window_start = idx;
}
(false, false) => {}
}
}
}
/* If the last part of the header value is encoded, it won't be pushed inside
* the previous for block */
if !is_current_window_ascii {
ret.extend_from_slice(
format!(
"=?UTF-8?B?{}?=",
base64_mime.encode(&value[current_window_start..]).trim()
)
.as_bytes(),
);
}
ret
}
}

pub use helpers::*;
34 changes: 20 additions & 14 deletions mailpot/src/message_filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,21 +166,26 @@ impl PostFilter for AddListHeaders {
) -> std::result::Result<(&'p mut PostEntry, &'p mut ListContext<'list>), ()> {
trace!("Running AddListHeaders filter");
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
let sender = format!("<{}>", ctx.list.address);
headers.push((HeaderName::SENDER, sender.as_bytes()));

let list_id = Some(ctx.list.id_header());
let list_help = ctx.list.help_header();
let list_post = ctx.list.post_header(ctx.post_policy.as_deref());
let map_fn = |x| crate::encode_header_owned(String::into_bytes(x));

let sender = Some(format!("<{}>", ctx.list.address)).map(map_fn);

let list_id = Some(map_fn(ctx.list.id_header()));
let list_help = ctx.list.help_header().map(map_fn);
let list_post = ctx.list.post_header(ctx.post_policy.as_deref()).map(map_fn);
let list_unsubscribe = ctx
.list
.unsubscribe_header(ctx.subscription_policy.as_deref());
.unsubscribe_header(ctx.subscription_policy.as_deref())
.map(map_fn);
let list_subscribe = ctx
.list
.subscribe_header(ctx.subscription_policy.as_deref());
let list_archive = ctx.list.archive_header();
.subscribe_header(ctx.subscription_policy.as_deref())
.map(map_fn);
let list_archive = ctx.list.archive_header().map(map_fn);

for (hdr, val) in [
(HeaderName::SENDER, &sender),
(HeaderName::LIST_ID, &list_id),
(HeaderName::LIST_HELP, &list_help),
(HeaderName::LIST_POST, &list_post),
Expand All @@ -189,7 +194,7 @@ impl PostFilter for AddListHeaders {
(HeaderName::LIST_ARCHIVE, &list_archive),
] {
if let Some(val) = val {
headers.push((hdr, val.as_bytes()));
headers.push((hdr, val.as_slice()));
}
}

Expand Down Expand Up @@ -239,11 +244,12 @@ impl PostFilter for AddSubjectTagPrefix {
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
let mut subject;
if let Some((_, subj_val)) = headers.iter_mut().find(|(k, _)| k == HeaderName::SUBJECT) {
subject = format!("[{}] ", ctx.list.id).into_bytes();
subject = crate::encode_header_owned(format!("[{}] ", ctx.list.id).into_bytes());
subject.extend(subj_val.iter().cloned());
*subj_val = subject.as_slice();
} else {
subject = format!("[{}] (no subject)", ctx.list.id).into_bytes();
subject =
crate::encode_header_owned(format!("[{}] (no subject)", ctx.list.id).into_bytes());
headers.push((HeaderName::SUBJECT, subject.as_slice()));
}

Expand Down Expand Up @@ -293,7 +299,7 @@ impl PostFilter for ArchivedAtLink {

let env = minijinja::Environment::new();
let message_id = post.message_id.to_string();
let header_val = env
let header_val = crate::encode_header_owned(env
.render_named_str(
"ArchivedAtLinkSettings.template",
&template,
Expand All @@ -309,9 +315,9 @@ impl PostFilter for ArchivedAtLink {
)
.map_err(|err| {
log::error!("ArchivedAtLink: {}", err);
})?;
})?.into_bytes());
let (mut headers, body) = melib::email::parser::mail(&post.bytes).unwrap();
headers.push((HeaderName::ARCHIVED_AT, header_val.as_bytes()));
headers.push((HeaderName::ARCHIVED_AT, header_val.as_slice()));

let mut new_vec = Vec::with_capacity(
headers
Expand Down
169 changes: 169 additions & 0 deletions mailpot/tests/message_filters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

use mailpot::{models::*, queue::Queue, Configuration, Connection, SendMail};
use mailpot_tests::init_stderr_logging;
use serde_json::json;
use tempfile::TempDir;

#[test]
fn test_post_filters() {
init_stderr_logging();
let tmp_dir = TempDir::new().unwrap();

let mut post_policy = PostPolicy {
pk: -1,
list: -1,
announce_only: false,
subscription_only: false,
approval_needed: false,
open: true,
custom: false,
};
let db_path = tmp_dir.path().join("mpot.db");
let config = Configuration {
send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
db_path,
data_path: tmp_dir.path().to_path_buf(),
administrators: vec![],
};

let db = Connection::open_or_create_db(config).unwrap().trusted();
let foo_chat = db
.create_list(MailingList {
pk: 0,
name: "foobar chat".into(),
id: "foo-chat".into(),
address: "[email protected]".into(),
description: None,
topics: vec![],
archive_url: None,
})
.unwrap();
post_policy.list = foo_chat.pk();
db.add_subscription(
foo_chat.pk(),
ListSubscription {
pk: -1,
list: foo_chat.pk(),
address: "[email protected]".into(),
name: None,
account: None,
digest: false,
enabled: true,
verified: true,
hide_address: false,
receive_duplicates: true,
receive_own_posts: true,
receive_confirmation: false,
},
)
.unwrap();
db.set_list_post_policy(post_policy).unwrap();

println!("Check that List subject prefix is inserted and can be optionally disabled…");
let post_bytes = b"From: Name <[email protected]>
To: <[email protected]>
Subject: This is a post
Date: Thu, 29 Oct 2020 13:58:16 +0000
Message-ID: <[email protected]>
Content-Language: en-US
Content-Type: text/html
Content-Transfer-Encoding: base64
MIME-Version: 1.0
PCFET0NUWVBFPjxodG1sPjxoZWFkPjx0aXRsZT5mb288L3RpdGxlPjwvaGVhZD48Ym9k
eT48dGFibGUgY2xhc3M9ImZvbyI+PHRoZWFkPjx0cj48dGQ+Zm9vPC90ZD48L3RoZWFk
Pjx0Ym9keT48dHI+PHRkPmZvbzE8L3RkPjwvdHI+PC90Ym9keT48L3RhYmxlPjwvYm9k
eT48L2h0bWw+
";
let envelope = melib::Envelope::from_bytes(post_bytes, None).expect("Could not parse message");
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
let q = db.queue(Queue::Out).unwrap();
assert_eq!(&q[0].subject, "[foo-chat] This is a post");

db.delete_from_queue(Queue::Out, vec![]).unwrap();
{
let mut stmt = db
.connection
.prepare(
"INSERT INTO list_settings_json(name, list, value) \
VALUES('AddSubjectTagPrefixSettings', ?, ?) RETURNING *;",
)
.unwrap();
stmt.query_row(
rusqlite::params![
&foo_chat.pk(),
&json!({
"enabled": false
}),
],
|_| Ok(()),
)
.unwrap();
}
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
let q = db.queue(Queue::Out).unwrap();
assert_eq!(&q[0].subject, "This is a post");
db.delete_from_queue(Queue::Out, vec![]).unwrap();

println!("Check that List headers are encoded with MIME when necessary…");
db.update_list(changesets::MailingListChangeset {
pk: foo_chat.pk,
description: Some(Some(
"Why, I, in this weak piping time of peace,\nHave no delight to pass away the \
time,\nUnless to spy my shadow in the sun."
.to_string(),
)),
..Default::default()
})
.unwrap();
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
let q = db.queue(Queue::Out).unwrap();
let q_env = melib::Envelope::from_bytes(&q[0].message, None).expect("Could not parse message");
assert_eq!(
&q_env.other_headers[melib::HeaderName::LIST_ID],
"Why, I, in this weak piping time of peace,\nHave no delight to pass away the \
time,\nUnless to spy my shadow in the sun. <foo-chat.example.com>"
);
db.delete_from_queue(Queue::Out, vec![]).unwrap();

db.update_list(changesets::MailingListChangeset {
pk: foo_chat.pk,
description: Some(Some(
r#"<p>Discussion about mailpot, a mailing list manager software.</p>
<ul>
<li>Main git repository: <a href="https://git.meli-email.org/meli/mailpot">https://git.meli-email.org/meli/mailpot</a></li>
<li>Mirror: <a href="https://github.com/meli/mailpot/">https://github.com/meli/mailpot/</a></li>
</ul>"#
.to_string(),
)),
..Default::default()
})
.unwrap();
db.post(&envelope, post_bytes, /* dry_run */ false).unwrap();
let q = db.queue(Queue::Out).unwrap();
let q_env = melib::Envelope::from_bytes(&q[0].message, None).expect("Could not parse message");
assert_eq!(
&q_env.other_headers[melib::HeaderName::LIST_ID],
"<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>"
);
}
Loading

0 comments on commit f425cf0

Please sign in to comment.