Skip to main content

mail_auth/report/arf/
parse.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use crate::{
8    common::headers::HeaderIterator,
9    report::{AuthFailureType, DeliveryResult, Error, Feedback, FeedbackType, IdentityAlignment},
10};
11use mail_parser::{HeaderValue, MessageParser, MimeHeaders, PartType, parsers::MessageStream};
12use std::borrow::Cow;
13
14impl<'x> Feedback<'x> {
15    pub fn parse_rfc5322(message: &'x [u8]) -> Result<Self, Error> {
16        let message = MessageParser::new()
17            .parse(message)
18            .ok_or(Error::MailParseError)?;
19        let mut feedback = None;
20        let mut included_message = None;
21        let mut included_headers = None;
22
23        for part in message.parts {
24            let arf = match part.body {
25                PartType::Text(arf) | PartType::Html(arf)
26                    if part.is_content_type("message", "feedback-report") =>
27                {
28                    match arf {
29                        Cow::Borrowed(arf) => Cow::Borrowed(arf.as_bytes()),
30                        Cow::Owned(arf) => Cow::Owned(arf.into_bytes()),
31                    }
32                }
33                PartType::Binary(arf) | PartType::InlineBinary(arf)
34                    if part.is_content_type("message", "feedback-report") =>
35                {
36                    arf
37                }
38                PartType::Text(headers) if part.is_content_type("text", "rfc822-headers") => {
39                    included_headers = match headers {
40                        Cow::Borrowed(arf) => Cow::Borrowed(arf.as_bytes()),
41                        Cow::Owned(arf) => Cow::Owned(arf.into_bytes()),
42                    }
43                    .into();
44                    continue;
45                }
46                PartType::Message(message) => {
47                    included_message = match message.raw_message {
48                        Cow::Borrowed(message) => Cow::Borrowed(
49                            message
50                                .get(part.offset_body as usize..part.offset_end as usize)
51                                .unwrap_or_default(),
52                        ),
53                        message => message,
54                    }
55                    .into();
56                    continue;
57                }
58                _ => continue,
59            };
60
61            feedback = match arf {
62                Cow::Borrowed(arf) => Feedback::parse_arf(arf),
63                Cow::Owned(arf) => Feedback::parse_arf(&arf).map(|f| f.into_owned()),
64            };
65        }
66
67        if let Some(mut feedback) = feedback {
68            for (feedback, included) in [
69                (&mut feedback.message, included_message),
70                (&mut feedback.headers, included_headers),
71            ] {
72                if let Some(included) = included {
73                    *feedback = match included {
74                        Cow::Borrowed(bytes) => Some(String::from_utf8_lossy(bytes)),
75                        Cow::Owned(bytes) => Some(
76                            String::from_utf8(bytes)
77                                .unwrap_or_else(|err| {
78                                    String::from_utf8_lossy(err.as_bytes()).into_owned()
79                                })
80                                .into(),
81                        ),
82                    };
83                }
84            }
85
86            Ok(feedback)
87        } else {
88            Err(Error::NoReportsFound)
89        }
90    }
91
92    pub fn parse_arf(arf: &'x [u8]) -> Option<Self> {
93        let mut f = Feedback {
94            incidents: 1,
95            ..Default::default()
96        };
97        let mut has_ft = false;
98
99        let mut fields = HeaderIterator::new(arf);
100        fields.seek_start();
101
102        for (key, value) in fields {
103            let txt_value = std::str::from_utf8(value).unwrap_or_default().trim();
104
105            match key.first() {
106                Some(b'A' | b'a') => {
107                    if key.eq_ignore_ascii_case(b"Arrival-Date") {
108                        if let HeaderValue::DateTime(dt) = MessageStream::new(value).parse_date() {
109                            f.arrival_date = dt.to_timestamp().into();
110                        }
111                    } else if key.eq_ignore_ascii_case(b"Auth-Failure") {
112                        f.auth_failure = if txt_value.eq_ignore_ascii_case("adsp") {
113                            AuthFailureType::Adsp
114                        } else if txt_value.eq_ignore_ascii_case("bodyhash") {
115                            AuthFailureType::BodyHash
116                        } else if txt_value.eq_ignore_ascii_case("revoked") {
117                            AuthFailureType::Revoked
118                        } else if txt_value.eq_ignore_ascii_case("signature") {
119                            AuthFailureType::Signature
120                        } else if txt_value.eq_ignore_ascii_case("spf") {
121                            AuthFailureType::Spf
122                        } else if txt_value.eq_ignore_ascii_case("dmarc") {
123                            AuthFailureType::Dmarc
124                        } else {
125                            continue;
126                        };
127                    } else if key.eq_ignore_ascii_case(b"Authentication-Results") {
128                        f.authentication_results.push(txt_value.into());
129                    }
130                }
131                Some(b'D' | b'd') => {
132                    if key.eq_ignore_ascii_case(b"DKIM-ADSP-DNS") {
133                        f.dkim_adsp_dns = Some(txt_value.into());
134                    } else if key.eq_ignore_ascii_case(b"DKIM-Canonicalized-Body") {
135                        f.dkim_canonicalized_body = Some(txt_value.into());
136                    } else if key.eq_ignore_ascii_case(b"DKIM-Canonicalized-Header") {
137                        f.dkim_canonicalized_header = Some(txt_value.into());
138                    } else if key.eq_ignore_ascii_case(b"DKIM-Domain") {
139                        f.dkim_domain = Some(txt_value.into());
140                    } else if key.eq_ignore_ascii_case(b"DKIM-Identity") {
141                        f.dkim_identity = Some(txt_value.into());
142                    } else if key.eq_ignore_ascii_case(b"DKIM-Selector") {
143                        f.dkim_selector = Some(txt_value.into());
144                    } else if key.eq_ignore_ascii_case(b"DKIM-Selector-DNS") {
145                        f.dkim_selector_dns = Some(txt_value.into());
146                    } else if key.eq_ignore_ascii_case(b"Delivery-Result") {
147                        f.delivery_result = if txt_value.eq_ignore_ascii_case("delivered") {
148                            DeliveryResult::Delivered
149                        } else if txt_value.eq_ignore_ascii_case("spam") {
150                            DeliveryResult::Spam
151                        } else if txt_value.eq_ignore_ascii_case("policy") {
152                            DeliveryResult::Policy
153                        } else if txt_value.eq_ignore_ascii_case("reject") {
154                            DeliveryResult::Reject
155                        } else if txt_value.eq_ignore_ascii_case("other") {
156                            DeliveryResult::Other
157                        } else {
158                            continue;
159                        };
160                    }
161                }
162                Some(b'F' | b'f') => {
163                    if key.eq_ignore_ascii_case(b"Feedback-Type") {
164                        f.feedback_type = if txt_value.eq_ignore_ascii_case("abuse") {
165                            FeedbackType::Abuse
166                        } else if txt_value.eq_ignore_ascii_case("auth-failure") {
167                            FeedbackType::AuthFailure
168                        } else if txt_value.eq_ignore_ascii_case("fraud") {
169                            FeedbackType::Fraud
170                        } else if txt_value.eq_ignore_ascii_case("not-spam") {
171                            FeedbackType::NotSpam
172                        } else if txt_value.eq_ignore_ascii_case("other") {
173                            FeedbackType::Other
174                        } else if txt_value.eq_ignore_ascii_case("virus") {
175                            FeedbackType::Virus
176                        } else {
177                            continue;
178                        };
179                        has_ft = true;
180                    }
181                }
182                Some(b'I' | b'i') => {
183                    if key.eq_ignore_ascii_case(b"Identity-Alignment") {
184                        for id in txt_value.split(',') {
185                            let id = id.trim();
186                            if id.eq_ignore_ascii_case("dkim") {
187                                f.identity_alignment =
188                                    if f.identity_alignment == IdentityAlignment::Spf {
189                                        IdentityAlignment::DkimSpf
190                                    } else {
191                                        IdentityAlignment::Dkim
192                                    };
193                            } else if id.eq_ignore_ascii_case("spf") {
194                                f.identity_alignment =
195                                    if f.identity_alignment == IdentityAlignment::Dkim {
196                                        IdentityAlignment::DkimSpf
197                                    } else {
198                                        IdentityAlignment::Spf
199                                    };
200                            } else if id.eq_ignore_ascii_case("none") {
201                                f.identity_alignment = IdentityAlignment::None;
202                                break;
203                            }
204                        }
205                    } else if key.eq_ignore_ascii_case(b"Incidents") {
206                        f.incidents = txt_value.parse().unwrap_or(1);
207                    }
208                }
209                Some(b'O' | b'o') => {
210                    if key.eq_ignore_ascii_case(b"Original-Envelope-Id") {
211                        f.original_envelope_id = Some(txt_value.into());
212                    } else if key.eq_ignore_ascii_case(b"Original-Mail-From") {
213                        f.original_mail_from = Some(txt_value.into());
214                    } else if key.eq_ignore_ascii_case(b"Original-Rcpt-To") {
215                        f.original_rcpt_to = Some(txt_value.into());
216                    }
217                }
218                Some(b'R' | b'r') => {
219                    if key.eq_ignore_ascii_case(b"Reported-Domain") {
220                        f.reported_domain.push(txt_value.into());
221                    } else if key.eq_ignore_ascii_case(b"Reported-URI") {
222                        f.reported_uri.push(txt_value.into());
223                    } else if key.eq_ignore_ascii_case(b"Reporting-MTA") {
224                        f.reporting_mta = Some(if let Some(mta) = txt_value.strip_prefix("dns;") {
225                            mta.trim().into()
226                        } else {
227                            txt_value.into()
228                        });
229                    } else if key.eq_ignore_ascii_case(b"Received-Date")
230                        && let HeaderValue::DateTime(dt) = MessageStream::new(value).parse_date()
231                    {
232                        f.arrival_date = dt.to_timestamp().into();
233                    }
234                }
235                Some(b'S' | b's') => {
236                    if key.eq_ignore_ascii_case(b"SPF-DNS") {
237                        f.spf_dns = Some(txt_value.into());
238                    } else if key.eq_ignore_ascii_case(b"Source-IP") {
239                        f.source_ip = if let Some((ip, _)) = txt_value.split_once(' ') {
240                            ip.parse().ok()
241                        } else {
242                            txt_value.parse().ok()
243                        };
244                    } else if key.eq_ignore_ascii_case(b"Source-Port") {
245                        f.source_port = txt_value.parse().unwrap_or(0);
246                    }
247                }
248                Some(b'U' | b'u') => {
249                    if key.eq_ignore_ascii_case(b"User-Agent") {
250                        f.user_agent = Some(txt_value.into());
251                    }
252                }
253                Some(b'V' | b'v') => {
254                    if key.eq_ignore_ascii_case(b"Version") {
255                        f.version = txt_value.parse().unwrap_or(0);
256                    }
257                }
258                _ => (),
259            }
260        }
261
262        if has_ft { Some(f) } else { None }
263    }
264}
265
266#[cfg(test)]
267mod test {
268    use std::{fs, path::PathBuf};
269
270    use crate::report::Feedback;
271
272    #[test]
273    fn arf_report_parse() {
274        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
275        test_dir.push("resources");
276        test_dir.push("arf");
277
278        for file_name in fs::read_dir(&test_dir).unwrap() {
279            let mut file_name = file_name.unwrap().path();
280            if !file_name.extension().unwrap().to_str().unwrap().eq("eml") {
281                continue;
282            }
283            println!("Parsing ARF feedback {}", file_name.to_str().unwrap());
284
285            let arf = fs::read(&file_name).unwrap();
286            let mut feedback = Feedback::parse_rfc5322(&arf).unwrap();
287            feedback.message = None;
288
289            file_name.set_extension("json");
290
291            let expected_feedback =
292                serde_json::from_slice::<Feedback>(&fs::read(&file_name).unwrap()).unwrap();
293
294            assert_eq!(expected_feedback, feedback);
295
296            /*fs::write(
297                &file_name,
298                serde_json::to_string_pretty(&feedback).unwrap().as_bytes(),
299            )
300            .unwrap();*/
301        }
302    }
303}