diff --git a/python/tests/test_account.py b/python/tests/test_account.py index a196addbf..0e45a0328 100644 --- a/python/tests/test_account.py +++ b/python/tests/test_account.py @@ -986,7 +986,10 @@ class TestOnlineAccount: chat = acfactory.get_accepted_chat(ac1, ac2) lp.sec("sending multi-line non-unicode message from ac1 to ac2") - text1 = "hello\nworld" + text1 = ( + "hello\nworld\nthis is a very long message that should be" + + " wrapped using format=flowed and unwrapped on the receiver" + ) msg_out = chat.send_text(text1) assert not msg_out.is_encrypted() diff --git a/src/format_flowed.rs b/src/format_flowed.rs new file mode 100644 index 000000000..bf3fe14e1 --- /dev/null +++ b/src/format_flowed.rs @@ -0,0 +1,136 @@ +///! # format=flowed support +///! +///! Format=flowed is defined in +///! [RFC 3676](https://tools.ietf.org/html/rfc3676). +///! +///! Older [RFC 2646](https://tools.ietf.org/html/rfc2646) is used +///! during formatting, i.e., DelSp parameter introduced in RFC 3676 +///! is assumed to be set to "no". +///! +///! For received messages, DelSp parameter is honoured. + +/// Wraps line to 72 characters using format=flowed soft breaks. +/// +/// 72 characters is the limit recommended by RFC 3676. +/// +/// The function breaks line only after SP and before non-whitespace +/// characters. It also does not insert breaks before ">" to avoid the +/// need to do space stuffing (see RFC 3676) for quotes. +/// +/// If there are long words, line may still exceed the limits on line +/// length. However, this should be rare and should not result in +/// immediate mail rejection: SMTP (RFC 2821) limit is 998 characters, +/// and Spam Assassin limit is 78 characters. +fn format_line_flowed(line: &str) -> String { + let mut result = String::new(); + let mut buffer = String::new(); + let mut after_space = false; + + for c in line.chars() { + if c == ' ' { + buffer.push(c); + after_space = true; + } else { + if after_space && buffer.len() >= 72 && !c.is_whitespace() && c != '>' { + // Flush the buffer and insert soft break (SP CRLF). + result += &buffer; + result += "\r\n"; + buffer = String::new(); + } + buffer.push(c); + after_space = false; + } + } + result + &buffer +} + +/// Returns text formatted according to RFC 3767 (format=flowed). +/// +/// This function accepts text separated by LF, but returns text +/// separated by CRLF. +/// +/// RFC 2646 technique is used to insert soft line breaks, so DelSp +/// SHOULD be set to "no" when sending. +pub fn format_flowed(text: &str) -> String { + let mut result = String::new(); + + for line in text.split('\n') { + if !result.is_empty() { + result += "\r\n"; + } + let line = line.trim_end(); + if line.len() > 78 { + result += &format_line_flowed(line); + } else { + result += line; + } + } + result +} + +/// Joins lines in format=flowed text. +/// +/// Lines must be separated by single LF. +/// +/// Quote processing is not supported, it is assumed that they are +/// deleted during simplification. +/// +/// Signature separator line is not processed here, it is assumed to +/// be stripped beforehand. +pub fn unformat_flowed(text: &str, delsp: bool) -> String { + let mut result = String::new(); + let mut skip_newline = true; + + for line in text.split('\n') { + // Revert space-stuffing + let line = line.strip_prefix(" ").unwrap_or(line); + + if !skip_newline { + result.push('\n'); + } + + if let Some(line) = line.strip_suffix(" ") { + // Flowed line + result += line; + if !delsp { + result.push(' '); + } + skip_newline = true; + } else { + // Fixed line + result += line; + skip_newline = false; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_flowed() { + let text = "Foo bar baz"; + assert_eq!(format_flowed(text), "Foo bar baz"); + + let text = "This is the Autocrypt Setup Message used to transfer your key between clients.\n\ + \n\ + To decrypt and use your key, open the message in an Autocrypt-compliant client and enter the setup code presented on the generating device."; + let expected = "This is the Autocrypt Setup Message used to transfer your key between clients.\r\n\ + \r\n\ + To decrypt and use your key, open the message in an Autocrypt-compliant \r\n\ + client and enter the setup code presented on the generating device."; + assert_eq!(format_flowed(text), expected); + } + + #[test] + fn test_unformat_flowed() { + let text = "this is a very long message that should be wrapped using format=flowed and \n\ + unwrapped on the receiver"; + let expected = + "this is a very long message that should be wrapped using format=flowed and \ + unwrapped on the receiver"; + assert_eq!(unformat_flowed(text, false), expected); + } +} diff --git a/src/lib.rs b/src/lib.rs index fbf82d0bf..c21980b55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ pub mod imex; mod scheduler; #[macro_use] pub mod job; +mod format_flowed; pub mod key; mod keyring; pub mod location; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 989dc3628..5d9304612 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -11,6 +11,7 @@ use crate::dc_tools::*; use crate::e2ee::*; use crate::ephemeral::Timer as EphemeralTimer; use crate::error::{bail, ensure, format_err, Error}; +use crate::format_flowed::format_flowed; use crate::location; use crate::message::{self, Message}; use crate::mimeparser::SystemMessage; @@ -910,11 +911,13 @@ impl<'a, 'b> MimeFactory<'a, 'b> { } }; + let flowed_text = format_flowed(final_text); + let footer = &self.selfstatus; let message_text = format!( "{}{}{}{}{}", fwdhint.unwrap_or_default(), - escape_message_footer_marks(final_text), + escape_message_footer_marks(&flowed_text), if !final_text.is_empty() && !footer.is_empty() { "\r\n\r\n" } else { @@ -926,7 +929,10 @@ impl<'a, 'b> MimeFactory<'a, 'b> { // Message is sent as text/plain, with charset = utf-8 let main_part = PartBuilder::new() - .content_type(&mime::TEXT_PLAIN_UTF_8) + .header(( + "Content-Type".to_string(), + "text/plain; charset=utf-8; format=flowed; delsp=no".to_string(), + )) .body(message_text); let mut parts = Vec::new(); diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 98cdbec1d..90ef747e2 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -17,6 +17,7 @@ use crate::dehtml::dehtml; use crate::e2ee; use crate::error::{bail, Result}; use crate::events::EventType; +use crate::format_flowed::unformat_flowed; use crate::headerdef::{HeaderDef, HeaderDefMap}; use crate::key::Fingerprint; use crate::location; @@ -715,6 +716,27 @@ impl MimeMessage { simplify(out, self.has_chat_version()) }; + let is_format_flowed = if let Some(format) = mail.ctype.params.get("format") + { + format.as_str().to_ascii_lowercase() == "flowed" + } else { + false + }; + + let simplified_txt = if mime_type.type_() == mime::TEXT + && mime_type.subtype() == mime::PLAIN + && is_format_flowed + { + let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") { + delsp.as_str().to_ascii_lowercase() == "yes" + } else { + false + }; + unformat_flowed(&simplified_txt, delsp) + } else { + simplified_txt + }; + if !simplified_txt.is_empty() { let mut part = Part::default(); part.typ = Viewtype::Text; @@ -2029,4 +2051,13 @@ CWt6wx7fiLp0qS9RrX75g6Gqw7nfCs6EcBERcIPt7DTe8VStJwf3LWqVwxl4gQl46yhfoqwEO+I= let test = parse_message_ids(" < ").unwrap(); assert!(test.is_empty()); } + + #[test] + fn test_mime_parse_format_flowed() { + let mime_type = "text/plain; charset=utf-8; Format=Flowed; DelSp=No" + .parse::() + .unwrap(); + let format_param = mime_type.get_param("format").unwrap(); + assert_eq!(format_param.as_str().to_ascii_lowercase(), "flowed"); + } }