//! Handle plain text together with some attributes. use std::sync::LazyLock; use crate::simplify::remove_message_footer; /// Plaintext message body together with format=flowed attributes. #[derive(Debug)] pub struct PlainText { /// The text itself. pub text: String, /// Text may "flowed" as defined in [RFC 2646](https://tools.ietf.org/html/rfc2646). /// At a glance, that means, if a line ends with a space, it is merged with the next one /// and the first leading spaces is ignored /// (to allow lines starting with `>` that normally indicates a quote) pub flowed: bool, /// If set together with "flowed", /// The space indicating merging two lines is removed. pub delsp: bool, } impl PlainText { /// Convert plain text to HTML. /// The function handles quotes, links, fixed and floating text paragraphs. pub fn to_html(&self) -> String { static LINKIFY_MAIL_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap()); static LINKIFY_URL_RE: LazyLock = LazyLock::new(|| { regex::Regex::new(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)").unwrap() }); let lines: Vec<&str> = self.text.lines().collect(); let (lines, _footer) = remove_message_footer(&lines); let mut ret = r#" "# .to_string(); for line in lines { let is_quote = line.starts_with('>'); // we need to do html-entity-encoding after linkify, as otherwise encapsulated links // as cannot be handled correctly // (they would become <http://example.org> where the trailing > would become a valid url part). // to avoid double encoding, we escape our html-entities by \r that must not be used in the string elsewhere. let line = line.to_string().replace('\r', ""); let mut line = LINKIFY_MAIL_RE .replace_all(&line, "\rLTa href=\rQUOTmailto:$1\rQUOT\rGT$1\rLT/a\rGT") .as_ref() .to_string(); line = LINKIFY_URL_RE .replace_all(&line, "\rLTa href=\rQUOT$1\rQUOT\rGT$1\rLT/a\rGT") .as_ref() .to_string(); // encode html-entities after linkify the raw string line = escaper::encode_minimal(&line); // make our escaped html-entities real after encoding all others line = line.replace("\rLT", "<"); line = line.replace("\rGT", ">"); line = line.replace("\rQUOT", "\""); if self.flowed { // flowed text as of RFC 3676 - // a leading space shall be removed // and is only there to allow > at the beginning of a line that is no quote. line = line.strip_prefix(' ').unwrap_or(&line).to_string(); if is_quote { line = "".to_owned() + &line + ""; } // a trailing space indicates that the line can be merged with the next one; // for sake of simplicity, we skip merging for quotes (quotes may be combined with // delsp, so `> >` is different from `>>` etc. see RFC 3676 for details) if line.ends_with(' ') && !is_quote { if self.delsp { line.pop(); } } else { line += "
\n"; } } else { // normal, fixed text if is_quote { line = "".to_owned() + &line + ""; } line += "
\n"; } let len_with_indentation = line.len(); let line = line.trim_start_matches(' '); for _ in line.len()..len_with_indentation { ret += " "; } ret += line; } ret += "\n"; ret } } #[cfg(test)] mod tests { use super::*; #[test] fn test_plain_to_html() { let html = PlainText { text: r##"line 1 line 2 line with https://link-mid-of-line.org and http://link-end-of-line.com/file?foo=bar%20 http://link-at-start-of-line.org "## .to_string(), flowed: false, delsp: false, } .to_html(); assert_eq!( html, r#" line 1
line 2
line with https://link-mid-of-line.org and http://link-end-of-line.com/file?foo=bar%20
http://link-at-start-of-line.org
"# ); } #[test] fn test_plain_remove_signature() { let html = PlainText { text: "Foo\nbar\n-- \nSignature here".to_string(), flowed: false, delsp: false, } .to_html(); assert_eq!( html, r#" Foo
bar
"# ); } #[test] fn test_plain_to_html_encapsulated() { let html = PlainText { text: r#"line with here!"#.to_string(), flowed: false, delsp: false, } .to_html(); assert_eq!( html, r#" line with <http://encapsulated.link/?foo=_bar> here!
"# ); } #[test] fn test_plain_to_html_nolink() { let html = PlainText { text: r#"line with nohttp://no.link here"#.to_string(), flowed: false, delsp: false, } .to_html(); assert_eq!( html, r#" line with nohttp://no.link here
"# ); } #[test] fn test_plain_to_html_mailto() { let html = PlainText { text: r#"just an address: foo@bar.org another@one.de"#.to_string(), flowed: false, delsp: false, } .to_html(); assert_eq!( html, r#" just an address: foo@bar.org another@one.de
"# ); } #[test] fn test_plain_to_html_flowed() { let html = PlainText { text: "line \nstill line\n>quote \n>still quote\n >no quote".to_string(), flowed: true, delsp: false, } .to_html(); assert_eq!( html, r#" line still line
>quote
>still quote
>no quote
"# ); } #[test] fn test_plain_to_html_flowed_delsp() { let html = PlainText { text: "line \nstill line\n>quote \n>still quote\n >no quote".to_string(), flowed: true, delsp: true, } .to_html(); assert_eq!( html, r#" linestill line
>quote
>still quote
>no quote
"# ); } #[test] fn test_plain_to_html_fixed() { let html = PlainText { text: "line \nstill line\n>quote \n>still quote\n >no quote".to_string(), flowed: false, delsp: false, } .to_html(); assert_eq!( html, r#" line
still line
>quote
>still quote
 >no quote
"# ); } #[test] fn test_plain_to_html_indentation() { let html = PlainText { text: "def foo():\n pass\n\ndef bar(x):\n return x + 5".to_string(), flowed: false, delsp: false, } .to_html(); assert_eq!( html, r#" def foo():
    pass

def bar(x):
    return x + 5
"# ); } }