1use regex::Regex;
2use std::sync::LazyLock;
3
4const MAX_URL_LENGTH: usize = 4096;
10
11const MAX_SUBJECT_THREAD_LENGTH: usize = 2048;
16
17static URL_RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r#"(?i)((https?|ftp)://[^\s<>"']+|www\.[^\s<>"']+)"#).unwrap());
19
20static EMAIL_RE: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})").unwrap());
22
23static UNRE_RE: LazyLock<Regex> = LazyLock::new(|| {
24 Regex::new(r"(?i)^(\s*(re|fwd?|aw|ang|sv|vs|odp|antw)\s*[\[:\]>#]*\s*)+")
25 .expect("UNRE_RE compile")
26});
27
28static ONEUNRE_RE: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(r"(?i)^\s*(re|fwd?|aw|ang|sv|vs|odp|antw)\s*[\[:\]>#]*\s*")
30 .expect("ONEUNRE_RE compile")
31});
32
33static STRIPZONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+\([^)]*\)\s*$").unwrap());
34
35static NUM_REF_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"&#(\d+);").unwrap());
36
37pub fn unre(subject: &str) -> String {
56 let truncated = if subject.len() > MAX_SUBJECT_THREAD_LENGTH {
59 &subject[..subject.floor_char_boundary(MAX_SUBJECT_THREAD_LENGTH)]
60 } else {
61 subject
62 };
63
64 UNRE_RE.replace(truncated, "").trim().to_string()
65}
66
67pub fn oneunre(subject: &str) -> String {
69 let truncated = if subject.len() > MAX_SUBJECT_THREAD_LENGTH {
72 &subject[..subject.floor_char_boundary(MAX_SUBJECT_THREAD_LENGTH)]
73 } else {
74 subject
75 };
76
77 ONEUNRE_RE.replace(truncated, "").trim().to_string()
78}
79
80pub fn parse_url(s: &str, url: &mut String) -> Option<usize> {
82 if let Some(m) = URL_RE.find(s) {
83 url.push_str(m.as_str());
84 Some(m.len())
85 } else {
86 None
87 }
88}
89
90pub fn conv_urls(line: &str) -> String {
108 if line.len() > MAX_URL_LENGTH * 10 {
111 return line.to_string();
112 }
113
114 URL_RE
115 .replace_all(line, |caps: ®ex::Captures| {
116 let url = &caps[1];
117
118 if url.len() > MAX_URL_LENGTH {
120 return url.to_string();
121 }
122
123 let href = if url.starts_with("www.") {
124 format!("https://{}", url)
125 } else {
126 url.to_string()
127 };
128 format!(
130 "<a href=\"{}\" rel=\"noopener noreferrer\">{}</a>",
131 escape_html_attr(&href),
132 escape_html_attr(url)
133 )
134 })
135 .to_string()
136}
137
138fn escape_html_attr(s: &str) -> String {
140 let mut result = String::with_capacity(s.len());
141 for c in s.chars() {
142 match c {
143 '&' => result.push_str("&"),
144 '"' => result.push_str("""),
145 '<' => result.push_str("<"),
146 '>' => result.push_str(">"),
147 '\'' => result.push_str("'"),
148 c => result.push(c),
149 }
150 }
151 result
152}
153
154pub fn obfuscate_email_address(s: &str) -> String {
156 let mut result = String::with_capacity(s.len());
157 for c in s.chars() {
158 match c {
159 '@' => result.push_str("@"),
160 '.' => result.push('.'),
161 '-' => result.push('-'),
162 '_' => result.push('_'),
163 c if c.is_ascii_alphanumeric() => {
164 let code = c as u32;
165 result.push_str(&format!("&#{};", code));
166 },
167 c => result.push(c),
168 }
169 }
170 result
171}
172
173pub fn unobfuscate_email_address(s: &str) -> String {
175 NUM_REF_RE
176 .replace_all(s, |caps: ®ex::Captures| {
177 let code: u32 = caps[1].parse().unwrap_or(0);
178 char::from_u32(code).map_or(String::new(), |c| c.to_string())
179 })
180 .to_string()
181}
182
183pub fn spamify(
187 s: &str,
188 antispam_at: &str,
189 antispamdomain: Option<&str>,
190 spamprotect: bool,
191 spamprotect_id: bool,
192) -> String {
193 if !spamprotect && !spamprotect_id {
194 return s.to_string();
195 }
196
197 if !EMAIL_RE.is_match(s) {
198 return s.to_string();
199 }
200
201 let result = EMAIL_RE.replace_all(s, |caps: ®ex::Captures| {
202 let email = &caps[1];
203 if let Some(domain) = antispamdomain {
204 if let Some(at_pos) = email.find('@') {
205 let local = &email[..at_pos];
206 return format!("{}@{}", local, domain);
207 }
208 }
209 if spamprotect {
210 email.replace('@', antispam_at)
211 } else {
212 email.to_string()
213 }
214 });
215
216 result.to_string()
217}
218
219pub fn convchars(s: &str, chars: &str) -> String {
221 let mut result = String::with_capacity(s.len());
222 for c in s.chars() {
223 if chars.contains(c) {
224 result.push('_');
225 } else {
226 result.push(c);
227 }
228 }
229 result
230}
231
232pub fn stripzone(s: &str) -> String {
234 STRIPZONE_RE.replace(s.trim(), "").to_string()
235}
236
237pub fn getvalue(s: &str) -> Option<&str> {
239 let s = s.trim();
240 if s.is_empty() || s.eq_ignore_ascii_case("NONE") {
241 None
242 } else {
243 Some(s)
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn test_unre() {
253 assert_eq!(unre("Re: Hello"), "Hello");
254 assert_eq!(unre("Re: Re: Hello"), "Hello");
255 assert_eq!(unre("Fwd: Hello"), "Hello");
256 assert_eq!(unre("Hello"), "Hello");
257 }
258
259 #[test]
260 fn test_conv_urls() {
261 let result = conv_urls("Visit https://example.com today");
262 assert!(result.contains("<a href=\"https://example.com\""));
263 assert!(result.contains("rel=\"noopener noreferrer\""));
264 }
265
266 #[test]
267 fn test_obfuscate_email() {
268 let ob = obfuscate_email_address("a@b.com");
269 assert!(ob.contains("a"));
270 assert!(ob.contains("@"));
271 }
272
273 #[test]
274 fn test_spamify() {
275 let result = spamify("a@b.com", " at ", None, true, false);
276 assert_eq!(result, "a at b.com");
277 }
278
279 #[test]
280 fn test_spamify_with_domain() {
281 let result = spamify("a@b.com", "@", Some("example.com"), true, false);
282 assert_eq!(result, "a@example.com");
283 }
284
285 #[test]
286 fn test_stripzone() {
287 let result = stripzone("Mon, 15 Mar 2021 12:00:00 +0000 (UTC)");
288 assert!(!result.contains("(UTC)"));
289 }
290
291 #[test]
292 fn test_getvalue() {
293 assert_eq!(getvalue("test"), Some("test"));
294 assert_eq!(getvalue("NONE"), None);
295 assert_eq!(getvalue(""), None);
296 }
297
298 #[test]
299 fn test_spamify_antispamdomain_replaces_domain() {
300 let result = spamify("user@real-domain.com", "_at_", Some("nospam.invalid"), true, false);
301 assert!(result.contains("nospam.invalid"), "domain should be replaced");
302 assert!(!result.contains("real-domain.com"), "original domain should be gone");
303 }
304
305 #[test]
306 fn test_spamify_antispamdomain_none_falls_back_to_at_replacement() {
307 let result = spamify("user@real-domain.com", "_at_", None, true, false);
308 assert!(result.contains("_at_"), "should use antispam_at when no antispamdomain");
309 assert!(!result.contains('@'), "@ should be replaced");
310 }
311
312 #[test]
313 fn test_convchars() {
314 assert_eq!(convchars("hello world", " "), "hello_world");
315 }
316
317 #[test]
318 fn test_oneunre_strips_single_prefix() {
319 assert_eq!(oneunre("Re: Hello"), "Hello");
320 assert_eq!(oneunre("Re: Re: Hello"), "Re: Hello");
321 assert_eq!(oneunre("Hello"), "Hello");
322 }
323
324 #[test]
325 fn test_parse_url_found() {
326 let mut url = String::new();
327 let len = parse_url("https://example.com/path?q=1", &mut url);
328 assert!(len.is_some());
329 assert_eq!(url, "https://example.com/path?q=1");
330 }
331
332 #[test]
333 fn test_parse_url_not_found() {
334 let mut url = String::new();
335 let len = parse_url("plain text no url", &mut url);
336 assert!(len.is_none());
337 assert!(url.is_empty());
338 }
339
340 #[test]
341 fn test_unobfuscate_roundtrip() {
342 let original = "user@example.com";
343 let obfuscated = obfuscate_email_address(original);
344 let restored = unobfuscate_email_address(&obfuscated);
345 assert_eq!(restored, original);
346 }
347
348 #[test]
349 fn test_spamify_no_email_unchanged() {
350 let result = spamify("no email here", " at ", None, true, false);
351 assert_eq!(result, "no email here");
352 }
353
354 #[test]
355 fn test_spamify_disabled_unchanged() {
356 let result = spamify("user@example.com", " at ", None, false, false);
357 assert_eq!(result, "user@example.com");
358 }
359
360 #[test]
361 fn test_conv_urls_escapes_href_with_quotes() {
362 let escaped_input =
366 crate::txt2html::escape_html(r#"Visit https://evil.com/a"onmouseover="alert(1) today"#);
367 let result = conv_urls(&escaped_input);
368 assert!(
371 !result.contains(r#""onmouseover"#),
372 "raw double-quote injection must not appear: {}",
373 result
374 );
375 }
376
377 #[test]
378 fn test_conv_urls_escapes_special_chars_in_href() {
379 let result = conv_urls("https://example.com/search?a=1&b=2");
381 assert!(
382 result.contains("&amp;") || result.contains("&b=2"),
383 "& in URL should be preserved or double-escaped in href attribute: {}",
384 result
385 );
386 }
387
388 #[test]
389 fn test_conv_urls_escapes_angle_brackets() {
390 let escaped_input = crate::txt2html::escape_html("https://evil.com/<script>");
392 let result = conv_urls(&escaped_input);
393 assert!(
394 !result.contains("<script>"),
395 "angle brackets should not appear raw in link output: {}",
396 result
397 );
398 }
399
400 #[test]
401 fn test_unre_utf8_at_truncation_boundary() {
402 let prefix = "Re: ";
405 let filler = "ä".repeat(1024); let subject = format!("{}{}", prefix, filler);
407 let result = unre(&subject);
409 assert!(!result.is_empty(), "should handle UTF-8 at truncation boundary");
410 }
411
412 #[test]
413 fn test_oneunre_utf8_at_truncation_boundary() {
414 let filler = "ö".repeat(1025); let subject = format!("Re: {}", filler);
416 let result = oneunre(&subject);
418 assert!(!result.is_empty());
419 }
420}