Skip to main content

hypermail/
filter.rs

1use crate::config::Config;
2use crate::date::{iso_to_secs, parse_rfc2822_date};
3use crate::message::FilteredReason;
4use regex::Regex;
5
6pub fn check_header_filter(headers: &[(String, String)], filter_list: &[String]) -> Vec<usize> {
7    let mut matches = Vec::new();
8    for (i, pattern) in filter_list.iter().enumerate() {
9        let re = match Regex::new(pattern) {
10            Ok(r) => r,
11            Err(e) => {
12                log::warn!("Invalid header filter regex '{}': {}", pattern, e);
13                continue;
14            },
15        };
16        for (name, value) in headers {
17            let header_line = format!("{}: {}", name, value);
18            if re.is_match(&header_line) || re.is_match(name) || re.is_match(value) {
19                matches.push(i);
20                break;
21            }
22        }
23    }
24    if matches.is_empty() {
25        matches
26    } else {
27        vec![matches[0]]
28    }
29}
30
31pub fn check_body_filter(body_lines: &[String], filter_list: &[String]) -> Vec<usize> {
32    let mut matches = Vec::new();
33    for (i, pattern) in filter_list.iter().enumerate() {
34        let re = match Regex::new(pattern) {
35            Ok(r) => r,
36            Err(e) => {
37                log::warn!("Invalid body filter regex '{}': {}", pattern, e);
38                continue;
39            },
40        };
41        for line in body_lines {
42            if re.is_match(line) {
43                matches.push(i);
44                break;
45            }
46        }
47    }
48    matches
49}
50
51pub fn check_deleted_headers(headers: &[(String, String)], deleted_list: &[String]) -> bool {
52    for (name, value) in headers {
53        if deleted_list.iter().any(|d| d.eq_ignore_ascii_case(name))
54            && value.trim().eq_ignore_ascii_case("yes")
55        {
56            return true;
57        }
58    }
59    false
60}
61
62fn parse_date_flexible(s: &str) -> Option<i64> {
63    parse_rfc2822_date(s).ok().or_else(|| iso_to_secs(s).ok())
64}
65
66pub fn check_expires_headers(headers: &[(String, String)], expires_list: &[String]) -> bool {
67    let now = std::time::SystemTime::now()
68        .duration_since(std::time::UNIX_EPOCH)
69        .unwrap_or_default()
70        .as_secs() as i64;
71    for (name, value) in headers {
72        if expires_list.iter().any(|e| e.eq_ignore_ascii_case(name)) {
73            if let Some(t) = parse_date_flexible(value.trim()) {
74                if t < now {
75                    return true;
76                }
77            }
78        }
79    }
80    false
81}
82
83pub fn check_delete_age(date: i64, delete_older: Option<&str>, delete_newer: Option<&str>) -> i32 {
84    let mut result = 0;
85    if let Some(older) = delete_older {
86        match parse_date_flexible(older) {
87            Some(threshold) => {
88                if date > 0 && date < threshold {
89                    result |= FilteredReason::FilteredOld as i32;
90                }
91            },
92            None => log::warn!("Could not parse delete_older date: {}", older),
93        }
94    }
95    if let Some(newer) = delete_newer {
96        match parse_date_flexible(newer) {
97            Some(threshold) => {
98                if date > 0 && date > threshold {
99                    result |= FilteredReason::FilteredNew as i32;
100                }
101            },
102            None => log::warn!("Could not parse delete_newer date: {}", newer),
103        }
104    }
105    result
106}
107
108pub fn check_delete_msgnum(msgnum: i32, delete_list: &[String]) -> bool {
109    for entry in delete_list {
110        if let Ok(n) = entry.parse::<i32>() {
111            if n == msgnum {
112                return true;
113            }
114        }
115    }
116    false
117}
118
119pub fn require_all_matched(require_results: &[bool]) -> bool {
120    require_results.iter().all(|&r| r)
121}
122
123pub fn apply_filters(
124    msgnum: i32,
125    headers: &[(String, String)],
126    body_lines: &[String],
127    date: i64,
128    config: &Config,
129) -> (i32, Vec<bool>) {
130    let mut is_deleted = 0;
131
132    if !config.filter_out.values.is_empty()
133        && !check_header_filter(headers, &config.filter_out.values).is_empty()
134    {
135        is_deleted |= FilteredReason::FilteredOut as i32;
136    }
137
138    if !config.filter_out_full_body.values.is_empty()
139        && !check_body_filter(body_lines, &config.filter_out_full_body.values).is_empty()
140    {
141        is_deleted |= FilteredReason::FilteredOut as i32;
142    }
143
144    if !config.deleted.values.is_empty() && check_deleted_headers(headers, &config.deleted.values) {
145        is_deleted |= FilteredReason::Delete as i32;
146    }
147
148    if !config.expires.values.is_empty() && check_expires_headers(headers, &config.expires.values) {
149        is_deleted |= FilteredReason::Expire as i32;
150    }
151
152    is_deleted |=
153        check_delete_age(date, config.delete_older.as_deref(), config.delete_newer.as_deref());
154
155    if check_delete_msgnum(msgnum, &config.delete_msgnum.values) {
156        is_deleted |= FilteredReason::Delete as i32;
157    }
158
159    let require_results: Vec<bool> = config
160        .filter_require
161        .values
162        .iter()
163        .map(|pattern| {
164            let re = match Regex::new(pattern) {
165                Ok(r) => r,
166                Err(_) => return false,
167            };
168            headers.iter().any(|(name, value)| {
169                let header_line = format!("{}: {}", name, value);
170                re.is_match(&header_line) || re.is_match(name) || re.is_match(value)
171            })
172        })
173        .collect();
174
175    let require_body_results: Vec<bool> = config
176        .filter_require_full_body
177        .values
178        .iter()
179        .map(|pattern| {
180            let re = match Regex::new(pattern) {
181                Ok(r) => r,
182                Err(_) => return false,
183            };
184            body_lines.iter().any(|line| re.is_match(line))
185        })
186        .collect();
187
188    let all_require = [require_results, require_body_results].concat();
189    (is_deleted, all_require)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::config::Config;
196
197    #[test]
198    fn test_check_header_filter_match() {
199        let headers = vec![("subject".to_string(), "test message".to_string())];
200        let filters = vec!["test".to_string()];
201        assert!(!check_header_filter(&headers, &filters).is_empty());
202    }
203
204    #[test]
205    fn test_check_header_filter_no_match() {
206        let headers = vec![("subject".to_string(), "hello".to_string())];
207        let filters = vec!["test".to_string()];
208        assert!(check_header_filter(&headers, &filters).is_empty());
209    }
210
211    #[test]
212    fn test_check_body_filter_match() {
213        let lines = vec!["hello world".to_string(), "spam content".to_string()];
214        let filters = vec!["spam".to_string()];
215        assert!(!check_body_filter(&lines, &filters).is_empty());
216    }
217
218    #[test]
219    fn test_check_body_filter_no_match() {
220        let lines = vec!["hello world".to_string()];
221        let filters = vec!["spam".to_string()];
222        assert!(check_body_filter(&lines, &filters).is_empty());
223    }
224
225    #[test]
226    fn test_check_deleted_headers_match() {
227        let headers = vec![("x-hypermail-deleted".to_string(), "yes".to_string())];
228        let deleted = vec!["X-Hypermail-Deleted".to_string()];
229        assert!(check_deleted_headers(&headers, &deleted));
230    }
231
232    #[test]
233    fn test_check_deleted_headers_no_value() {
234        let headers = vec![("x-hypermail-deleted".to_string(), "no".to_string())];
235        let deleted = vec!["X-Hypermail-Deleted".to_string()];
236        assert!(!check_deleted_headers(&headers, &deleted));
237    }
238
239    #[test]
240    fn test_check_expires_past() {
241        let headers = vec![("expires".to_string(), "2000-01-01T00:00:00Z".to_string())];
242        let expires = vec!["Expires".to_string()];
243        assert!(check_expires_headers(&headers, &expires));
244    }
245
246    #[test]
247    fn test_check_expires_future() {
248        let headers = vec![("expires".to_string(), "Mon, 01 Jan 2099 00:00:00 +0000".to_string())];
249        let expires = vec!["Expires".to_string()];
250        assert!(!check_expires_headers(&headers, &expires));
251    }
252
253    #[test]
254    fn test_delete_older() {
255        let result = check_delete_age(1000, Some("2000-01-01"), None);
256        assert!(result & FilteredReason::FilteredOld as i32 != 0);
257    }
258
259    #[test]
260    fn test_delete_newer() {
261        let result = check_delete_age(9999999999, None, Some("2000-01-01"));
262        assert!(result & FilteredReason::FilteredNew as i32 != 0);
263    }
264
265    #[test]
266    fn test_check_delete_msgnum_match() {
267        let list = vec!["5".to_string(), "10".to_string()];
268        assert!(check_delete_msgnum(5, &list));
269        assert!(check_delete_msgnum(10, &list));
270        assert!(!check_delete_msgnum(7, &list));
271    }
272
273    #[test]
274    fn test_apply_filters_basic() {
275        let config = Config::default();
276        let headers = vec![("subject".to_string(), "hello".to_string())];
277        let body = vec!["some body".to_string()];
278        let (deleted, _) = apply_filters(1, &headers, &body, 1000, &config);
279        assert_eq!(deleted, 0);
280    }
281
282    #[test]
283    fn test_require_all_matched_true() {
284        assert!(require_all_matched(&[true, true, true]));
285    }
286
287    #[test]
288    fn test_require_all_matched_false() {
289        assert!(!require_all_matched(&[true, false, true]));
290    }
291
292    #[test]
293    fn test_require_all_matched_empty_is_true() {
294        assert!(require_all_matched(&[]));
295    }
296
297    #[test]
298    fn test_apply_filters_filter_out_header_match() {
299        use crate::message::FilteredReason;
300        let mut config = Config::default();
301        config.filter_out.values.push("spam".to_string());
302        let headers = vec![("subject".to_string(), "spam message".to_string())];
303        let (deleted, _) = apply_filters(1, &headers, &[], 1000, &config);
304        assert_ne!(deleted & FilteredReason::FilteredOut as i32, 0);
305    }
306
307    #[test]
308    fn test_apply_filters_deleted_header() {
309        use crate::message::FilteredReason;
310        let mut config = Config::default();
311        config.deleted.values.push("X-Hypermail-Deleted".to_string());
312        let headers = vec![("x-hypermail-deleted".to_string(), "yes".to_string())];
313        let (deleted, _) = apply_filters(1, &headers, &[], 1000, &config);
314        assert_ne!(deleted & FilteredReason::Delete as i32, 0);
315    }
316
317    #[test]
318    fn test_apply_filters_delete_msgnum() {
319        use crate::message::FilteredReason;
320        let mut config = Config::default();
321        config.delete_msgnum.values.push("42".to_string());
322        let (deleted, _) = apply_filters(42, &[], &[], 1000, &config);
323        assert_ne!(deleted & FilteredReason::Delete as i32, 0);
324    }
325
326    #[test]
327    fn test_check_header_filter_matches_header_name() {
328        let headers = vec![("x-spam-flag".to_string(), "no".to_string())];
329        let filters = vec!["x-spam-flag".to_string()];
330        assert!(!check_header_filter(&headers, &filters).is_empty());
331    }
332}