Skip to content

Commit be922ee

Browse files
Alexander Krotovlink2xt
Alexander Krotov
authored and
link2xt
committed
Format plain text as Format=Flowed DelSp=No
This avoids triggering spam filters which require that lines are wrapped to 78 characters.
1 parent 1325b2f commit be922ee

File tree

5 files changed

+180
-3
lines changed

5 files changed

+180
-3
lines changed

python/tests/test_account.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,10 @@ def test_send_first_message_as_long_unicode_with_cr(self, acfactory, lp):
986986
chat = acfactory.get_accepted_chat(ac1, ac2)
987987

988988
lp.sec("sending multi-line non-unicode message from ac1 to ac2")
989-
text1 = "hello\nworld"
989+
text1 = (
990+
"hello\nworld\nthis is a very long message that should be"
991+
+ " wrapped using format=flowed and unwrapped on the receiver"
992+
)
990993
msg_out = chat.send_text(text1)
991994
assert not msg_out.is_encrypted()
992995

src/format_flowed.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
///! # format=flowed support
2+
///!
3+
///! Format=flowed is defined in
4+
///! [RFC 3676](https://tools.ietf.org/html/rfc3676).
5+
///!
6+
///! Older [RFC 2646](https://tools.ietf.org/html/rfc2646) is used
7+
///! during formatting, i.e., DelSp parameter introduced in RFC 3676
8+
///! is assumed to be set to "no".
9+
///!
10+
///! For received messages, DelSp parameter is honoured.
11+
12+
/// Wraps line to 72 characters using format=flowed soft breaks.
13+
///
14+
/// 72 characters is the limit recommended by RFC 3676.
15+
///
16+
/// The function breaks line only after SP and before non-whitespace
17+
/// characters. It also does not insert breaks before ">" to avoid the
18+
/// need to do space stuffing (see RFC 3676) for quotes.
19+
///
20+
/// If there are long words, line may still exceed the limits on line
21+
/// length. However, this should be rare and should not result in
22+
/// immediate mail rejection: SMTP (RFC 2821) limit is 998 characters,
23+
/// and Spam Assassin limit is 78 characters.
24+
fn format_line_flowed(line: &str) -> String {
25+
let mut result = String::new();
26+
let mut buffer = String::new();
27+
let mut after_space = false;
28+
29+
for c in line.chars() {
30+
if c == ' ' {
31+
buffer.push(c);
32+
after_space = true;
33+
} else {
34+
if after_space && buffer.len() >= 72 && !c.is_whitespace() && c != '>' {
35+
// Flush the buffer and insert soft break (SP CRLF).
36+
result += &buffer;
37+
result += "\r\n";
38+
buffer = String::new();
39+
}
40+
buffer.push(c);
41+
after_space = false;
42+
}
43+
}
44+
result + &buffer
45+
}
46+
47+
/// Returns text formatted according to RFC 3767 (format=flowed).
48+
///
49+
/// This function accepts text separated by LF, but returns text
50+
/// separated by CRLF.
51+
///
52+
/// RFC 2646 technique is used to insert soft line breaks, so DelSp
53+
/// SHOULD be set to "no" when sending.
54+
pub fn format_flowed(text: &str) -> String {
55+
let mut result = String::new();
56+
57+
for line in text.split('\n') {
58+
if !result.is_empty() {
59+
result += "\r\n";
60+
}
61+
let line = line.trim_end();
62+
if line.len() > 78 {
63+
result += &format_line_flowed(line);
64+
} else {
65+
result += line;
66+
}
67+
}
68+
result
69+
}
70+
71+
/// Joins lines in format=flowed text.
72+
///
73+
/// Lines must be separated by single LF.
74+
///
75+
/// Quote processing is not supported, it is assumed that they are
76+
/// deleted during simplification.
77+
///
78+
/// Signature separator line is not processed here, it is assumed to
79+
/// be stripped beforehand.
80+
pub fn unformat_flowed(text: &str, delsp: bool) -> String {
81+
let mut result = String::new();
82+
let mut skip_newline = true;
83+
84+
for line in text.split('\n') {
85+
// Revert space-stuffing
86+
let line = line.strip_prefix(" ").unwrap_or(line);
87+
88+
if !skip_newline {
89+
result.push('\n');
90+
}
91+
92+
if let Some(line) = line.strip_suffix(" ") {
93+
// Flowed line
94+
result += line;
95+
if !delsp {
96+
result.push(' ');
97+
}
98+
skip_newline = true;
99+
} else {
100+
// Fixed line
101+
result += line;
102+
skip_newline = false;
103+
}
104+
}
105+
result
106+
}
107+
108+
#[cfg(test)]
109+
mod tests {
110+
use super::*;
111+
112+
#[test]
113+
fn test_format_flowed() {
114+
let text = "Foo bar baz";
115+
assert_eq!(format_flowed(text), "Foo bar baz");
116+
117+
let text = "This is the Autocrypt Setup Message used to transfer your key between clients.\n\
118+
\n\
119+
To decrypt and use your key, open the message in an Autocrypt-compliant client and enter the setup code presented on the generating device.";
120+
let expected = "This is the Autocrypt Setup Message used to transfer your key between clients.\r\n\
121+
\r\n\
122+
To decrypt and use your key, open the message in an Autocrypt-compliant \r\n\
123+
client and enter the setup code presented on the generating device.";
124+
assert_eq!(format_flowed(text), expected);
125+
}
126+
127+
#[test]
128+
fn test_unformat_flowed() {
129+
let text = "this is a very long message that should be wrapped using format=flowed and \n\
130+
unwrapped on the receiver";
131+
let expected =
132+
"this is a very long message that should be wrapped using format=flowed and \
133+
unwrapped on the receiver";
134+
assert_eq!(unformat_flowed(text, false), expected);
135+
}
136+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pub mod imex;
5454
mod scheduler;
5555
#[macro_use]
5656
pub mod job;
57+
mod format_flowed;
5758
pub mod key;
5859
mod keyring;
5960
pub mod location;

src/mimefactory.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::dc_tools::*;
1111
use crate::e2ee::*;
1212
use crate::ephemeral::Timer as EphemeralTimer;
1313
use crate::error::{bail, ensure, format_err, Error};
14+
use crate::format_flowed::format_flowed;
1415
use crate::location;
1516
use crate::message::{self, Message};
1617
use crate::mimeparser::SystemMessage;
@@ -910,11 +911,13 @@ impl<'a, 'b> MimeFactory<'a, 'b> {
910911
}
911912
};
912913

914+
let flowed_text = format_flowed(final_text);
915+
913916
let footer = &self.selfstatus;
914917
let message_text = format!(
915918
"{}{}{}{}{}",
916919
fwdhint.unwrap_or_default(),
917-
escape_message_footer_marks(final_text),
920+
escape_message_footer_marks(&flowed_text),
918921
if !final_text.is_empty() && !footer.is_empty() {
919922
"\r\n\r\n"
920923
} else {
@@ -926,7 +929,10 @@ impl<'a, 'b> MimeFactory<'a, 'b> {
926929

927930
// Message is sent as text/plain, with charset = utf-8
928931
let main_part = PartBuilder::new()
929-
.content_type(&mime::TEXT_PLAIN_UTF_8)
932+
.header((
933+
"Content-Type".to_string(),
934+
"text/plain; charset=utf-8; format=flowed; delsp=no".to_string(),
935+
))
930936
.body(message_text);
931937
let mut parts = Vec::new();
932938

src/mimeparser.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::dehtml::dehtml;
1717
use crate::e2ee;
1818
use crate::error::{bail, Result};
1919
use crate::events::EventType;
20+
use crate::format_flowed::unformat_flowed;
2021
use crate::headerdef::{HeaderDef, HeaderDefMap};
2122
use crate::key::Fingerprint;
2223
use crate::location;
@@ -715,6 +716,27 @@ impl MimeMessage {
715716
simplify(out, self.has_chat_version())
716717
};
717718

719+
let is_format_flowed = if let Some(format) = mail.ctype.params.get("format")
720+
{
721+
format.as_str().to_ascii_lowercase() == "flowed"
722+
} else {
723+
false
724+
};
725+
726+
let simplified_txt = if mime_type.type_() == mime::TEXT
727+
&& mime_type.subtype() == mime::PLAIN
728+
&& is_format_flowed
729+
{
730+
let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") {
731+
delsp.as_str().to_ascii_lowercase() == "yes"
732+
} else {
733+
false
734+
};
735+
unformat_flowed(&simplified_txt, delsp)
736+
} else {
737+
simplified_txt
738+
};
739+
718740
if !simplified_txt.is_empty() {
719741
let mut part = Part::default();
720742
part.typ = Viewtype::Text;
@@ -2029,4 +2051,13 @@ CWt6wx7fiLp0qS9RrX75g6Gqw7nfCs6EcBERcIPt7DTe8VStJwf3LWqVwxl4gQl46yhfoqwEO+I=
20292051
let test = parse_message_ids(" < ").unwrap();
20302052
assert!(test.is_empty());
20312053
}
2054+
2055+
#[test]
2056+
fn test_mime_parse_format_flowed() {
2057+
let mime_type = "text/plain; charset=utf-8; Format=Flowed; DelSp=No"
2058+
.parse::<mime::Mime>()
2059+
.unwrap();
2060+
let format_param = mime_type.get_param("format").unwrap();
2061+
assert_eq!(format_param.as_str().to_ascii_lowercase(), "flowed");
2062+
}
20322063
}

0 commit comments

Comments
 (0)