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