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            hashify::fnc_map_ignore_case!(key,
106                b"Arrival-Date" => {
107                    if let HeaderValue::DateTime(dt) = MessageStream::new(value).parse_date() {
108                        f.arrival_date = dt.to_timestamp().into();
109                    }
110                },
111                b"Auth-Failure" => {
112                    f.auth_failure = match hashify::tiny_map_ignore_case!(txt_value.as_bytes(),
113                        b"adsp" => AuthFailureType::Adsp,
114                        b"bodyhash" => AuthFailureType::BodyHash,
115                        b"revoked" => AuthFailureType::Revoked,
116                        b"signature" => AuthFailureType::Signature,
117                        b"spf" => AuthFailureType::Spf,
118                        b"dmarc" => AuthFailureType::Dmarc,
119                    ) {
120                        Some(auth_failure) => auth_failure,
121                        None => continue,
122                    };
123                },
124                b"Authentication-Results" => {
125                    f.authentication_results.push(txt_value.into());
126                },
127                b"DKIM-ADSP-DNS" => {
128                    f.dkim_adsp_dns = Some(txt_value.into());
129                },
130                b"DKIM-Canonicalized-Body" => {
131                    f.dkim_canonicalized_body = Some(txt_value.into());
132                },
133                b"DKIM-Canonicalized-Header" => {
134                    f.dkim_canonicalized_header = Some(txt_value.into());
135                },
136                b"DKIM-Domain" => {
137                    f.dkim_domain = Some(txt_value.into());
138                },
139                b"DKIM-Identity" => {
140                    f.dkim_identity = Some(txt_value.into());
141                },
142                b"DKIM-Selector" => {
143                    f.dkim_selector = Some(txt_value.into());
144                },
145                b"DKIM-Selector-DNS" => {
146                    f.dkim_selector_dns = Some(txt_value.into());
147                },
148                b"Delivery-Result" => {
149                    f.delivery_result = match hashify::tiny_map_ignore_case!(txt_value.as_bytes(),
150                        b"delivered" => DeliveryResult::Delivered,
151                        b"spam" => DeliveryResult::Spam,
152                        b"policy" => DeliveryResult::Policy,
153                        b"reject" => DeliveryResult::Reject,
154                        b"other" => DeliveryResult::Other,
155                    ) {
156                        Some(delivery_result) => delivery_result,
157                        None => continue,
158                    };
159                },
160                b"Feedback-Type" => {
161                    f.feedback_type = match hashify::tiny_map_ignore_case!(txt_value.as_bytes(),
162                        b"abuse" => FeedbackType::Abuse,
163                        b"auth-failure" => FeedbackType::AuthFailure,
164                        b"fraud" => FeedbackType::Fraud,
165                        b"not-spam" => FeedbackType::NotSpam,
166                        b"other" => FeedbackType::Other,
167                        b"virus" => FeedbackType::Virus,
168                    ) {
169                        Some(feedback_type) => feedback_type,
170                        None => continue,
171                    };
172                    has_ft = true;
173                },
174                b"Identity-Alignment" => {
175                    for id in txt_value.split(',') {
176                        let id = id.trim();
177                        hashify::fnc_map_ignore_case!(id.as_bytes(),
178                            b"dkim" => {
179                                f.identity_alignment =
180                                    if f.identity_alignment == IdentityAlignment::Spf {
181                                        IdentityAlignment::DkimSpf
182                                    } else {
183                                        IdentityAlignment::Dkim
184                                    };
185                            },
186                            b"spf" => {
187                                f.identity_alignment =
188                                    if f.identity_alignment == IdentityAlignment::Dkim {
189                                        IdentityAlignment::DkimSpf
190                                    } else {
191                                        IdentityAlignment::Spf
192                                    };
193                            },
194                            b"none" => {
195                                f.identity_alignment = IdentityAlignment::None;
196                                break;
197                            },
198                            _ => ()
199                        );
200                    }
201                },
202                b"Incidents" => {
203                    f.incidents = txt_value.parse().unwrap_or(1);
204                },
205                b"Original-Envelope-Id" => {
206                    f.original_envelope_id = Some(txt_value.into());
207                },
208                b"Original-Mail-From" => {
209                    f.original_mail_from = Some(txt_value.into());
210                },
211                b"Original-Rcpt-To" => {
212                    f.original_rcpt_to = Some(txt_value.into());
213                },
214                b"Reported-Domain" => {
215                    f.reported_domain.push(txt_value.into());
216                },
217                b"Reported-URI" => {
218                    f.reported_uri.push(txt_value.into());
219                },
220                b"Reporting-MTA" => {
221                    f.reporting_mta = Some(if let Some(mta) = txt_value.strip_prefix("dns;") {
222                        mta.trim().into()
223                    } else {
224                        txt_value.into()
225                    });
226                },
227                b"Received-Date" => {
228                    if let HeaderValue::DateTime(dt) = MessageStream::new(value).parse_date() {
229                        f.arrival_date = dt.to_timestamp().into();
230                    }
231                },
232                b"SPF-DNS" => {
233                    f.spf_dns = Some(txt_value.into());
234                },
235                b"Source-IP" => {
236                    f.source_ip = if let Some((ip, _)) = txt_value.split_once(' ') {
237                        ip.parse().ok()
238                    } else {
239                        txt_value.parse().ok()
240                    };
241                },
242                b"Source-Port" => {
243                    f.source_port = txt_value.parse().unwrap_or(0);
244                },
245                b"User-Agent" => {
246                    f.user_agent = Some(txt_value.into());
247                },
248                b"Version" => {
249                    f.version = txt_value.parse().unwrap_or(0);
250                },
251                _ => ()
252            );
253        }
254
255        if has_ft { Some(f) } else { None }
256    }
257}
258
259#[cfg(test)]
260mod test {
261    use std::{fs, path::PathBuf};
262
263    use crate::report::Feedback;
264
265    #[test]
266    fn arf_report_parse() {
267        let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
268        test_dir.push("resources");
269        test_dir.push("arf");
270
271        for file_name in fs::read_dir(&test_dir).unwrap() {
272            let mut file_name = file_name.unwrap().path();
273            if !file_name.extension().unwrap().to_str().unwrap().eq("eml") {
274                continue;
275            }
276            println!("Parsing ARF feedback {}", file_name.to_str().unwrap());
277
278            let arf = fs::read(&file_name).unwrap();
279            let mut feedback = Feedback::parse_rfc5322(&arf).unwrap();
280            feedback.message = None;
281
282            file_name.set_extension("json");
283
284            let expected_feedback =
285                serde_json::from_slice::<Feedback>(&fs::read(&file_name).unwrap()).unwrap();
286
287            assert_eq!(expected_feedback, feedback);
288
289            /*fs::write(
290                &file_name,
291                serde_json::to_string_pretty(&feedback).unwrap().as_bytes(),
292            )
293            .unwrap();*/
294        }
295    }
296}