Skip to main content

mail_auth/report/arf/
generate.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::report::{AuthFailureType, DeliveryResult, Feedback, FeedbackType, IdentityAlignment};
8use mail_builder::{
9    MessageBuilder,
10    headers::{HeaderType, address::Address, content_type::ContentType},
11    mime::{BodyPart, MimePart, make_boundary},
12};
13use mail_parser::DateTime;
14use std::{fmt::Write, io, time::SystemTime};
15
16impl<'x> Feedback<'x> {
17    pub fn write_rfc5322(
18        &self,
19        from: impl Into<Address<'x>>,
20        to: &'x str,
21        subject: &'x str,
22        writer: impl io::Write,
23    ) -> io::Result<()> {
24        // Generate ARF
25        let arf = self.to_arf();
26
27        // Generate text/plain body
28        let mut text_body = String::with_capacity(128);
29        if self.feedback_type == FeedbackType::AuthFailure {
30            write!(
31                &mut text_body,
32                "This is an authentication failure report for an email message received\r\n"
33            )
34        } else {
35            write!(
36                &mut text_body,
37                "This is an email abuse report for an email message received\r\n"
38            )
39        }
40        .ok();
41        if let Some(ip) = &self.source_ip {
42            write!(&mut text_body, "from IP address {ip} ").ok();
43        }
44        let dt = DateTime::from_timestamp(if let Some(ad) = &self.arrival_date {
45            *ad
46        } else {
47            SystemTime::now()
48                .duration_since(SystemTime::UNIX_EPOCH)
49                .map(|d| d.as_secs())
50                .unwrap_or(0) as i64
51        });
52        write!(&mut text_body, "on {}.\r\n", dt.to_rfc822()).ok();
53
54        // Build message parts
55        let mut parts = vec![
56            MimePart::new(
57                ContentType::new("text/plain"),
58                BodyPart::Text(text_body.into()),
59            ),
60            MimePart::new(
61                ContentType::new("message/feedback-report"),
62                BodyPart::Text(arf.into()),
63            ),
64        ];
65        if let Some(message) = self.message.as_deref() {
66            parts.push(MimePart::new(
67                ContentType::new("message/rfc822"),
68                BodyPart::Text(message.into()),
69            ));
70        } else if let Some(headers) = self.headers.as_deref() {
71            parts.push(MimePart::new(
72                ContentType::new("text/rfc822-headers"),
73                BodyPart::Text(headers.into()),
74            ));
75        }
76
77        MessageBuilder::new()
78            .from(from)
79            .header("To", HeaderType::Text(to.into()))
80            .header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
81            .message_id(format!(
82                "{}@{}",
83                make_boundary("."),
84                self.reporting_mta().unwrap_or("localhost")
85            ))
86            .subject(subject)
87            .body(MimePart::new(
88                ContentType::new("multipart/report").attribute("report-type", "feedback-report"),
89                BodyPart::Multipart(parts),
90            ))
91            .write_to(writer)
92    }
93
94    pub fn to_rfc5322(
95        &self,
96        from: impl Into<Address<'x>>,
97        to: &'x str,
98        subject: &'x str,
99    ) -> io::Result<String> {
100        let mut buf = Vec::new();
101        self.write_rfc5322(from, to, subject, &mut buf)?;
102        String::from_utf8(buf).map_err(io::Error::other)
103    }
104
105    pub fn to_arf(&self) -> String {
106        let mut arf = String::with_capacity(128);
107
108        write!(&mut arf, "Version: {}\r\n", self.version).ok();
109        write!(
110            &mut arf,
111            "Feedback-Type: {}\r\n",
112            match self.feedback_type {
113                FeedbackType::Abuse => "abuse",
114                FeedbackType::AuthFailure => "auth-failure",
115                FeedbackType::Fraud => "fraud",
116                FeedbackType::NotSpam => "not-spam",
117                FeedbackType::Other => "other",
118                FeedbackType::Virus => "virus",
119            }
120        )
121        .ok();
122        if let Some(ad) = &self.arrival_date {
123            let ad = DateTime::from_timestamp(*ad);
124            write!(&mut arf, "Arrival-Date: {}\r\n", ad.to_rfc822()).ok();
125        }
126
127        if self.feedback_type == FeedbackType::AuthFailure {
128            if self.auth_failure != AuthFailureType::Unspecified {
129                write!(
130                    &mut arf,
131                    "Auth-Failure: {}\r\n",
132                    match self.auth_failure {
133                        AuthFailureType::Adsp => "adsp",
134                        AuthFailureType::BodyHash => "bodyhash",
135                        AuthFailureType::Revoked => "revoked",
136                        AuthFailureType::Signature => "signature",
137                        AuthFailureType::Spf => "spf",
138                        AuthFailureType::Dmarc => "dmarc",
139                        AuthFailureType::Unspecified => unreachable!(),
140                    }
141                )
142                .ok();
143            }
144
145            if self.delivery_result != DeliveryResult::Unspecified {
146                write!(
147                    &mut arf,
148                    "Delivery-Result: {}\r\n",
149                    match self.delivery_result {
150                        DeliveryResult::Delivered => "delivered",
151                        DeliveryResult::Spam => "spam",
152                        DeliveryResult::Policy => "policy",
153                        DeliveryResult::Reject => "reject",
154                        DeliveryResult::Other => "other",
155                        DeliveryResult::Unspecified => unreachable!(),
156                    }
157                )
158                .ok();
159            }
160            if let Some(value) = &self.dkim_adsp_dns {
161                write!(&mut arf, "DKIM-ADSP-DNS: {value}\r\n").ok();
162            }
163            if let Some(value) = &self.dkim_canonicalized_body {
164                write!(&mut arf, "DKIM-Canonicalized-Body: {value}\r\n").ok();
165            }
166            if let Some(value) = &self.dkim_canonicalized_header {
167                write!(&mut arf, "DKIM-Canonicalized-Header: {value}\r\n").ok();
168            }
169            if let Some(value) = &self.dkim_domain {
170                write!(&mut arf, "DKIM-Domain: {value}\r\n").ok();
171            }
172            if let Some(value) = &self.dkim_identity {
173                write!(&mut arf, "DKIM-Identity: {value}\r\n").ok();
174            }
175            if let Some(value) = &self.dkim_selector {
176                write!(&mut arf, "DKIM-Selector: {value}\r\n").ok();
177            }
178            if let Some(value) = &self.dkim_selector_dns {
179                write!(&mut arf, "DKIM-Selector-DNS: {value}\r\n").ok();
180            }
181            if let Some(value) = &self.spf_dns {
182                write!(&mut arf, "SPF-DNS: {value}\r\n").ok();
183            }
184            if self.identity_alignment != IdentityAlignment::Unspecified {
185                write!(
186                    &mut arf,
187                    "Identity-Alignment: {}\r\n",
188                    match self.identity_alignment {
189                        IdentityAlignment::None => "none",
190                        IdentityAlignment::Spf => "spf",
191                        IdentityAlignment::Dkim => "dkim",
192                        IdentityAlignment::DkimSpf => "dkim, spf",
193                        IdentityAlignment::Unspecified => unreachable!(),
194                    }
195                )
196                .ok();
197            }
198        }
199
200        for value in &self.authentication_results {
201            write!(&mut arf, "Authentication-Results: {value}\r\n").ok();
202        }
203        if self.incidents > 1 {
204            write!(&mut arf, "Incidents: {}\r\n", self.incidents).ok();
205        }
206        if let Some(value) = &self.original_envelope_id {
207            write!(&mut arf, "Original-Envelope-Id: {value}\r\n").ok();
208        }
209        if let Some(value) = &self.original_mail_from {
210            write!(&mut arf, "Original-Mail-From: {value}\r\n").ok();
211        }
212        if let Some(value) = &self.original_rcpt_to {
213            write!(&mut arf, "Original-Rcpt-To: {value}\r\n").ok();
214        }
215        for value in &self.reported_domain {
216            write!(&mut arf, "Reported-Domain: {value}\r\n").ok();
217        }
218        for value in &self.reported_uri {
219            write!(&mut arf, "Reported-URI: {value}\r\n").ok();
220        }
221        if let Some(value) = &self.reporting_mta {
222            write!(&mut arf, "Reporting-MTA: dns;{value}\r\n").ok();
223        }
224        if let Some(value) = &self.source_ip {
225            write!(&mut arf, "Source-IP: {value}\r\n").ok();
226        }
227        if self.source_port != 0 {
228            write!(&mut arf, "Source-Port: {}\r\n", self.source_port).ok();
229        }
230        if let Some(value) = &self.user_agent {
231            write!(&mut arf, "User-Agent: {value}\r\n").ok();
232        }
233
234        arf
235    }
236}
237
238#[cfg(test)]
239mod test {
240    use crate::report::{AuthFailureType, Feedback, FeedbackType, IdentityAlignment};
241
242    #[test]
243    fn arf_report_generate() {
244        let feedback = Feedback::new(FeedbackType::AuthFailure)
245            .with_arrival_date(5934759438)
246            .with_authentication_results("dkim=pass")
247            .with_incidents(10)
248            .with_original_envelope_id("821-abc-123")
249            .with_original_mail_from("hello@world.org")
250            .with_original_rcpt_to("ciao@mundo.org")
251            .with_reported_domain("example.org")
252            .with_reported_domain("example2.org")
253            .with_reported_uri("uri:domain.org")
254            .with_reported_uri("uri:domain2.org")
255            .with_reporting_mta("Manchegator 2.0")
256            .with_source_ip("192.168.1.1".parse().unwrap())
257            .with_user_agent("DMARC-Meister")
258            .with_version(2)
259            .with_source_port(1234)
260            .with_auth_failure(AuthFailureType::Dmarc)
261            .with_dkim_adsp_dns("v=dkim1")
262            .with_dkim_canonicalized_body("base64 goes here")
263            .with_dkim_canonicalized_header("more base64")
264            .with_dkim_domain("dkim-domain.org")
265            .with_dkim_identity("my-dkim-identity@domain.org")
266            .with_dkim_selector("the-selector")
267            .with_dkim_selector_dns("v=dkim1;")
268            .with_spf_dns("v=spf1")
269            .with_identity_alignment(IdentityAlignment::DkimSpf)
270            .with_message("From: hello@world.org\r\nTo: ciao@mondo.org\r\n\r\n");
271
272        let message = feedback
273            .to_rfc5322(
274                ("DMARC Reporter", "no-reply@example.org"),
275                "ruf@otherdomain.com",
276                "DMARC Authentication Failure Report",
277            )
278            .unwrap();
279
280        let parsed_feedback = Feedback::parse_rfc5322(message.as_bytes()).unwrap();
281
282        assert_eq!(feedback, parsed_feedback);
283    }
284}