1use std::sync::LazyLock;
4
5use crate::simplify::remove_message_footer;
6
7#[derive(Debug)]
9pub struct PlainText {
10 pub text: String,
12
13 pub flowed: bool,
18
19 pub delsp: bool,
22}
23
24impl PlainText {
25 #[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 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 line = escaper::encode_minimal(&line);
68
69 line = line.replace("\rLT", "<");
71 line = line.replace("\rGT", ">");
72 line = line.replace("\rQUOT", "\"");
73
74 if self.flowed {
75 line = line.strip_prefix(' ').unwrap_or(&line).to_string();
79 if is_quote {
80 line = "<em>".to_owned() + &line + "</em>";
81 }
82
83 if line.ends_with(' ') && !is_quote {
87 if self.delsp {
88 line.pop();
89 }
90 } else {
91 line += "<br/>\n";
92 }
93 } else {
94 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 += " ";
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 <<a href="http://encapsulated.link/?foo=_bar">http://encapsulated.link/?foo=_bar</a>> 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>>quote </em><br/>
248<em>>still quote</em><br/>
249>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>>quote </em><br/>
272<em>>still quote</em><br/>
273>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>>quote </em><br/>
297<em>>still quote</em><br/>
298 >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 pass<br/>
321<br/>
322def bar(x):<br/>
323 return x + 5<br/>
324</body></html>
325"#
326 );
327 }
328}