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}