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