mail_auth/report/arf/
parse.rs1use 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 }
302 }
303}