deltachat/
plaintext.rs

1//! Handle plain text together with some attributes.
2
3use std::sync::LazyLock;
4
5use crate::simplify::remove_message_footer;
6
7/// Plaintext message body together with format=flowed attributes.
8#[derive(Debug)]
9pub struct PlainText {
10    /// The text itself.
11    pub text: String,
12
13    /// Text may "flowed" as defined in [RFC 2646](https://tools.ietf.org/html/rfc2646).
14    /// At a glance, that means, if a line ends with a space, it is merged with the next one
15    /// and the first leading spaces is ignored
16    /// (to allow lines starting with `>` that normally indicates a quote)
17    pub flowed: bool,
18
19    /// If set together with "flowed",
20    /// The space indicating merging two lines is removed.
21    pub delsp: bool,
22}
23
24impl PlainText {
25    /// Convert plain text to HTML.
26    /// The function handles quotes, links, fixed and floating text paragraphs.
27    pub fn to_html(&self) -> String {
28        static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
29            LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());
30
31        static LINKIFY_URL_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
32            regex::Regex::new(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)").unwrap()
33        });
34
35        let lines: Vec<&str> = self.text.lines().collect();
36        let (lines, _footer) = remove_message_footer(&lines);
37
38        let mut ret = r#"<!DOCTYPE html>
39<html><head>
40<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
41<meta name="color-scheme" content="light dark" />
42</head><body>
43"#
44        .to_string();
45
46        for line in lines {
47            let is_quote = line.starts_with('>');
48
49            // we need to do html-entity-encoding after linkify, as otherwise encapsulated links
50            // as <http://example.org> cannot be handled correctly
51            // (they would become &lt;http://example.org&gt; where the trailing &gt; would become a valid url part).
52            // to avoid double encoding, we escape our html-entities by \r that must not be used in the string elsewhere.
53            let line = line.to_string().replace('\r', "");
54
55            let mut line = LINKIFY_MAIL_RE
56                .replace_all(&line, "\rLTa href=\rQUOTmailto:$1\rQUOT\rGT$1\rLT/a\rGT")
57                .as_ref()
58                .to_string();
59
60            line = LINKIFY_URL_RE
61                .replace_all(&line, "\rLTa href=\rQUOT$1\rQUOT\rGT$1\rLT/a\rGT")
62                .as_ref()
63                .to_string();
64
65            // encode html-entities after linkify the raw string
66            line = escaper::encode_minimal(&line);
67
68            // make our escaped html-entities real after encoding all others
69            line = line.replace("\rLT", "<");
70            line = line.replace("\rGT", ">");
71            line = line.replace("\rQUOT", "\"");
72
73            if self.flowed {
74                // flowed text as of RFC 3676 -
75                // a leading space shall be removed
76                // and is only there to allow > at the beginning of a line that is no quote.
77                line = line.strip_prefix(' ').unwrap_or(&line).to_string();
78                if is_quote {
79                    line = "<em>".to_owned() + &line + "</em>";
80                }
81
82                // a trailing space indicates that the line can be merged with the next one;
83                // for sake of simplicity, we skip merging for quotes (quotes may be combined with
84                // delsp, so `> >` is different from `>>` etc. see RFC 3676 for details)
85                if line.ends_with(' ') && !is_quote {
86                    if self.delsp {
87                        line.pop();
88                    }
89                } else {
90                    line += "<br/>\n";
91                }
92            } else {
93                // normal, fixed text
94                if is_quote {
95                    line = "<em>".to_owned() + &line + "</em>";
96                }
97                line += "<br/>\n";
98            }
99
100            let len_with_indentation = line.len();
101            let line = line.trim_start_matches(' ');
102            for _ in line.len()..len_with_indentation {
103                ret += "&nbsp;";
104            }
105            ret += line;
106        }
107        ret += "</body></html>\n";
108        ret
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_plain_to_html() {
118        let html = PlainText {
119            text: r##"line 1
120line 2
121line with https://link-mid-of-line.org and http://link-end-of-line.com/file?foo=bar%20
122http://link-at-start-of-line.org
123"##
124            .to_string(),
125            flowed: false,
126            delsp: false,
127        }
128        .to_html();
129        assert_eq!(
130            html,
131            r#"<!DOCTYPE html>
132<html><head>
133<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
134<meta name="color-scheme" content="light dark" />
135</head><body>
136line 1<br/>
137line 2<br/>
138line with <a href="https://link-mid-of-line.org">https://link-mid-of-line.org</a> and <a href="http://link-end-of-line.com/file?foo=bar%20">http://link-end-of-line.com/file?foo=bar%20</a><br/>
139<a href="http://link-at-start-of-line.org">http://link-at-start-of-line.org</a><br/>
140</body></html>
141"#
142        );
143    }
144
145    #[test]
146    fn test_plain_remove_signature() {
147        let html = PlainText {
148            text: "Foo\nbar\n-- \nSignature here".to_string(),
149            flowed: false,
150            delsp: false,
151        }
152        .to_html();
153        assert_eq!(
154            html,
155            r#"<!DOCTYPE html>
156<html><head>
157<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
158<meta name="color-scheme" content="light dark" />
159</head><body>
160Foo<br/>
161bar<br/>
162</body></html>
163"#
164        );
165    }
166
167    #[test]
168    fn test_plain_to_html_encapsulated() {
169        let html = PlainText {
170            text: r#"line with <http://encapsulated.link/?foo=_bar> here!"#.to_string(),
171            flowed: false,
172            delsp: false,
173        }
174        .to_html();
175        assert_eq!(
176            html,
177            r#"<!DOCTYPE html>
178<html><head>
179<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
180<meta name="color-scheme" content="light dark" />
181</head><body>
182line with &lt;<a href="http://encapsulated.link/?foo=_bar">http://encapsulated.link/?foo=_bar</a>&gt; here!<br/>
183</body></html>
184"#
185        );
186    }
187
188    #[test]
189    fn test_plain_to_html_nolink() {
190        let html = PlainText {
191            text: r#"line with nohttp://no.link here"#.to_string(),
192            flowed: false,
193            delsp: false,
194        }
195        .to_html();
196        assert_eq!(
197            html,
198            r#"<!DOCTYPE html>
199<html><head>
200<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
201<meta name="color-scheme" content="light dark" />
202</head><body>
203line with nohttp://no.link here<br/>
204</body></html>
205"#
206        );
207    }
208
209    #[test]
210    fn test_plain_to_html_mailto() {
211        let html = PlainText {
212            text: r#"just an address: foo@bar.org another@one.de"#.to_string(),
213            flowed: false,
214            delsp: false,
215        }
216        .to_html();
217        assert_eq!(
218            html,
219            r#"<!DOCTYPE html>
220<html><head>
221<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
222<meta name="color-scheme" content="light dark" />
223</head><body>
224just an address: <a href="mailto:foo@bar.org">foo@bar.org</a> <a href="mailto:another@one.de">another@one.de</a><br/>
225</body></html>
226"#
227        );
228    }
229
230    #[test]
231    fn test_plain_to_html_flowed() {
232        let html = PlainText {
233            text: "line \nstill line\n>quote \n>still quote\n >no quote".to_string(),
234            flowed: true,
235            delsp: false,
236        }
237        .to_html();
238        assert_eq!(
239            html,
240            r#"<!DOCTYPE html>
241<html><head>
242<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
243<meta name="color-scheme" content="light dark" />
244</head><body>
245line still line<br/>
246<em>&gt;quote </em><br/>
247<em>&gt;still quote</em><br/>
248&gt;no quote<br/>
249</body></html>
250"#
251        );
252    }
253
254    #[test]
255    fn test_plain_to_html_flowed_delsp() {
256        let html = PlainText {
257            text: "line \nstill line\n>quote \n>still quote\n >no quote".to_string(),
258            flowed: true,
259            delsp: true,
260        }
261        .to_html();
262        assert_eq!(
263            html,
264            r#"<!DOCTYPE html>
265<html><head>
266<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
267<meta name="color-scheme" content="light dark" />
268</head><body>
269linestill line<br/>
270<em>&gt;quote </em><br/>
271<em>&gt;still quote</em><br/>
272&gt;no quote<br/>
273</body></html>
274"#
275        );
276    }
277
278    #[test]
279    fn test_plain_to_html_fixed() {
280        let html = PlainText {
281            text: "line \nstill line\n>quote \n>still quote\n >no quote".to_string(),
282            flowed: false,
283            delsp: false,
284        }
285        .to_html();
286        assert_eq!(
287            html,
288            r#"<!DOCTYPE html>
289<html><head>
290<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
291<meta name="color-scheme" content="light dark" />
292</head><body>
293line <br/>
294still line<br/>
295<em>&gt;quote </em><br/>
296<em>&gt;still quote</em><br/>
297&nbsp;&gt;no quote<br/>
298</body></html>
299"#
300        );
301    }
302
303    #[test]
304    fn test_plain_to_html_indentation() {
305        let html = PlainText {
306            text: "def foo():\n    pass\n\ndef bar(x):\n    return x + 5".to_string(),
307            flowed: false,
308            delsp: false,
309        }
310        .to_html();
311        assert_eq!(
312            html,
313            r#"<!DOCTYPE html>
314<html><head>
315<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
316<meta name="color-scheme" content="light dark" />
317</head><body>
318def foo():<br/>
319&nbsp;&nbsp;&nbsp;&nbsp;pass<br/>
320<br/>
321def bar(x):<br/>
322&nbsp;&nbsp;&nbsp;&nbsp;return x + 5<br/>
323</body></html>
324"#
325        );
326    }
327}