mail_auth/common/
auth_results.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 std::{
8    borrow::Cow,
9    fmt::{Display, Write},
10    net::IpAddr,
11};
12
13use mail_builder::encoders::base64::base64_encode;
14
15use crate::{
16    ArcOutput, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error,
17    IprevOutput, IprevResult, ReceivedSpf, SpfOutput, SpfResult,
18};
19
20use super::headers::{HeaderWriter, Writer};
21
22impl<'x> AuthenticationResults<'x> {
23    pub fn new(hostname: &'x str) -> Self {
24        AuthenticationResults {
25            hostname,
26            auth_results: String::with_capacity(64),
27        }
28    }
29
30    pub fn with_dkim_results(mut self, dkim: &[DkimOutput], header_from: &str) -> Self {
31        for dkim in dkim {
32            self.set_dkim_result(dkim, header_from);
33        }
34        self
35    }
36
37    pub fn with_dkim_result(mut self, dkim: &DkimOutput, header_from: &str) -> Self {
38        self.set_dkim_result(dkim, header_from);
39        self
40    }
41
42    pub fn set_dkim_result(&mut self, dkim: &DkimOutput, header_from: &str) {
43        if !dkim.is_atps {
44            self.auth_results.push_str(";\r\n\tdkim=");
45        } else {
46            self.auth_results.push_str(";\r\n\tdkim-atps=");
47        }
48        dkim.result.as_auth_result(&mut self.auth_results);
49        if let Some(signature) = &dkim.signature {
50            if !signature.i.is_empty() {
51                self.auth_results.push_str(" header.i=");
52                self.auth_results.push_str(&signature.i);
53            } else {
54                self.auth_results.push_str(" header.d=");
55                self.auth_results.push_str(&signature.d);
56            }
57            self.auth_results.push_str(" header.s=");
58            self.auth_results.push_str(&signature.s);
59            if signature.b.len() >= 6 {
60                self.auth_results.push_str(" header.b=");
61                self.auth_results.push_str(
62                    &String::from_utf8(base64_encode(&signature.b[..6]).unwrap_or_default())
63                        .unwrap_or_default(),
64                );
65            }
66        }
67
68        if dkim.is_atps {
69            write!(self.auth_results, " header.from={header_from}").ok();
70        }
71    }
72
73    pub fn with_spf_ehlo_result(
74        mut self,
75        spf: &SpfOutput,
76        ip_addr: IpAddr,
77        ehlo_domain: &str,
78    ) -> Self {
79        self.auth_results.push_str(";\r\n\tspf=");
80        spf.result.as_spf_result(
81            &mut self.auth_results,
82            self.hostname,
83            &format!("postmaster@{ehlo_domain}"),
84            ip_addr,
85        );
86        write!(self.auth_results, " smtp.helo={ehlo_domain}").ok();
87        self
88    }
89
90    pub fn with_spf_mailfrom_result(
91        mut self,
92        spf: &SpfOutput,
93        ip_addr: IpAddr,
94        from: &str,
95        ehlo_domain: &str,
96    ) -> Self {
97        let (mail_from, addr) = if !from.is_empty() {
98            (Cow::from(from), from)
99        } else {
100            (format!("postmaster@{ehlo_domain}").into(), "<>")
101        };
102        self.auth_results.push_str(";\r\n\tspf=");
103        spf.result.as_spf_result(
104            &mut self.auth_results,
105            self.hostname,
106            mail_from.as_ref(),
107            ip_addr,
108        );
109        write!(self.auth_results, " smtp.mailfrom={addr}").ok();
110        self
111    }
112
113    pub fn with_arc_result(mut self, arc: &ArcOutput, remote_ip: IpAddr) -> Self {
114        self.auth_results.push_str(";\r\n\tarc=");
115        arc.result.as_auth_result(&mut self.auth_results);
116        let _ = write!(self.auth_results, " smtp.remote-ip=");
117        let _ = format_ip_as_pvalue(&mut self.auth_results, remote_ip);
118        self
119    }
120
121    pub fn with_dmarc_result(mut self, dmarc: &DmarcOutput) -> Self {
122        self.auth_results.push_str(";\r\n\tdmarc=");
123        if dmarc.spf_result == DmarcResult::Pass || dmarc.dkim_result == DmarcResult::Pass {
124            DmarcResult::Pass.as_auth_result(&mut self.auth_results);
125        } else if dmarc.spf_result != DmarcResult::None {
126            dmarc.spf_result.as_auth_result(&mut self.auth_results);
127        } else if dmarc.dkim_result != DmarcResult::None {
128            dmarc.dkim_result.as_auth_result(&mut self.auth_results);
129        } else {
130            DmarcResult::None.as_auth_result(&mut self.auth_results);
131        }
132        write!(
133            self.auth_results,
134            " header.from={} policy.dmarc={}",
135            dmarc.domain, dmarc.policy
136        )
137        .ok();
138        self
139    }
140
141    pub fn with_iprev_result(mut self, iprev: &IprevOutput, remote_ip: IpAddr) -> Self {
142        self.auth_results.push_str(";\r\n\tiprev=");
143        iprev.result.as_auth_result(&mut self.auth_results);
144        let _ = write!(self.auth_results, " policy.iprev=");
145        let _ = format_ip_as_pvalue(&mut self.auth_results, remote_ip);
146        self
147    }
148}
149
150impl Display for AuthenticationResults<'_> {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.write_str(self.hostname)?;
153        f.write_str(&self.auth_results)
154    }
155}
156
157impl HeaderWriter for AuthenticationResults<'_> {
158    fn write_header(&self, writer: &mut impl Writer) {
159        writer.write(b"Authentication-Results: ");
160        writer.write(self.hostname.as_bytes());
161        if !self.auth_results.is_empty() {
162            writer.write(self.auth_results.as_bytes());
163        } else {
164            writer.write(b"; none");
165        }
166        writer.write(b"\r\n");
167    }
168}
169
170impl HeaderWriter for ReceivedSpf {
171    fn write_header(&self, writer: &mut impl Writer) {
172        writer.write(b"Received-SPF: ");
173        writer.write(self.received_spf.as_bytes());
174        writer.write(b"\r\n");
175    }
176}
177
178impl ReceivedSpf {
179    pub fn new(
180        spf: &SpfOutput,
181        ip_addr: IpAddr,
182        helo: &str,
183        mail_from: &str,
184        hostname: &str,
185    ) -> Self {
186        let mut received_spf = String::with_capacity(64);
187        let mail_from = if !mail_from.is_empty() {
188            Cow::from(mail_from)
189        } else {
190            format!("postmaster@{helo}").into()
191        };
192
193        spf.result
194            .as_spf_result(&mut received_spf, hostname, mail_from.as_ref(), ip_addr);
195
196        write!(
197            received_spf,
198            "\r\n\treceiver={hostname}; client-ip={ip_addr}; envelope-from=\"{mail_from}\"; helo={helo};",
199        )
200        .ok();
201
202        ReceivedSpf { received_spf }
203    }
204}
205
206impl SpfResult {
207    fn as_spf_result(&self, header: &mut String, hostname: &str, mail_from: &str, ip_addr: IpAddr) {
208        match &self {
209            SpfResult::Pass => write!(
210                header,
211                "pass ({hostname}: domain of {mail_from} designates {ip_addr} as permitted sender)",
212            ),
213            SpfResult::Fail => write!(
214                header,
215                "fail ({hostname}: domain of {mail_from} does not designate {ip_addr} as permitted sender)",
216            ),
217            SpfResult::SoftFail => write!(
218                header,
219                "softfail ({hostname}: domain of {mail_from} reports soft fail for {ip_addr})",
220            ),
221            SpfResult::Neutral => write!(
222                header,
223                "neutral ({hostname}: domain of {mail_from} reports neutral for {ip_addr})",
224            ),
225            SpfResult::TempError => write!(
226                header,
227                "temperror ({hostname}: temporary dns error validating {mail_from})",
228            ),
229            SpfResult::PermError => write!(
230                header,
231                "permerror ({hostname}: unable to verify SPF record for {mail_from})",
232            ),
233            SpfResult::None => write!(
234                header,
235                "none ({hostname}: no SPF records found for {mail_from})",
236            ),
237        }
238        .ok();
239    }
240}
241
242pub trait AsAuthResult {
243    fn as_auth_result(&self, header: &mut String);
244}
245
246impl AsAuthResult for DmarcResult {
247    fn as_auth_result(&self, header: &mut String) {
248        match &self {
249            DmarcResult::Pass => header.push_str("pass"),
250            DmarcResult::Fail(err) => {
251                header.push_str("fail");
252                err.as_auth_result(header);
253            }
254            DmarcResult::PermError(err) => {
255                header.push_str("permerror");
256                err.as_auth_result(header);
257            }
258            DmarcResult::TempError(err) => {
259                header.push_str("temperror");
260                err.as_auth_result(header);
261            }
262            DmarcResult::None => header.push_str("none"),
263        }
264    }
265}
266
267impl AsAuthResult for IprevResult {
268    fn as_auth_result(&self, header: &mut String) {
269        match &self {
270            IprevResult::Pass => header.push_str("pass"),
271            IprevResult::Fail(err) => {
272                header.push_str("fail");
273                err.as_auth_result(header);
274            }
275            IprevResult::PermError(err) => {
276                header.push_str("permerror");
277                err.as_auth_result(header);
278            }
279            IprevResult::TempError(err) => {
280                header.push_str("temperror");
281                err.as_auth_result(header);
282            }
283            IprevResult::None => header.push_str("none"),
284        }
285    }
286}
287
288impl AsAuthResult for DkimResult {
289    fn as_auth_result(&self, header: &mut String) {
290        match &self {
291            DkimResult::Pass => header.push_str("pass"),
292            DkimResult::Neutral(err) => {
293                header.push_str("neutral");
294                err.as_auth_result(header);
295            }
296            DkimResult::Fail(err) => {
297                header.push_str("fail");
298                err.as_auth_result(header);
299            }
300            DkimResult::PermError(err) => {
301                header.push_str("permerror");
302                err.as_auth_result(header);
303            }
304            DkimResult::TempError(err) => {
305                header.push_str("temperror");
306                err.as_auth_result(header);
307            }
308            DkimResult::None => header.push_str("none"),
309        }
310    }
311}
312
313impl AsAuthResult for Error {
314    fn as_auth_result(&self, header: &mut String) {
315        header.push_str(" (");
316        header.push_str(match self {
317            Error::ParseError => "dns record parse error",
318            Error::MissingParameters => "missing parameters",
319            Error::NoHeadersFound => "no headers found",
320            Error::CryptoError(_) => "verification failed",
321            Error::Io(_) => "i/o error",
322            Error::Base64 => "base64 error",
323            Error::UnsupportedVersion => "unsupported version",
324            Error::UnsupportedAlgorithm => "unsupported algorithm",
325            Error::UnsupportedCanonicalization => "unsupported canonicalization",
326            Error::UnsupportedKeyType => "unsupported key type",
327            Error::FailedBodyHashMatch => "body hash did not verify",
328            Error::FailedVerification => "verification failed",
329            Error::FailedAuidMatch => "auid does not match",
330            Error::RevokedPublicKey => "revoked public key",
331            Error::IncompatibleAlgorithms => "incompatible record/signature algorithms",
332            Error::SignatureExpired => "signature error",
333            Error::DnsError(_) => "dns error",
334            Error::DnsRecordNotFound(_) => "dns record not found",
335            Error::ArcInvalidInstance(i) => {
336                write!(header, "invalid ARC instance {i})").ok();
337                return;
338            }
339            Error::ArcInvalidCV => "invalid ARC cv",
340            Error::ArcChainTooLong => "too many ARC headers",
341            Error::ArcHasHeaderTag => "ARC has header tag",
342            Error::ArcBrokenChain => "broken ARC chain",
343            Error::NotAligned => "policy not aligned",
344            Error::InvalidRecordType => "invalid dns record type",
345            Error::SignatureLength => "signature length ignored due to security risk",
346        });
347        header.push(')');
348    }
349}
350
351/// Encodes the IP address to be used in a [`pvalue`] field.
352///
353/// IPv4 addresses can be used as-is, but IPv6 addresses need to be quoted
354/// since they contain `:` characters.
355///
356/// [`pvalue`]: https://datatracker.ietf.org/doc/html/rfc8601#section-2.2
357fn format_ip_as_pvalue(w: &mut impl Write, ip: IpAddr) -> std::fmt::Result {
358    match ip {
359        IpAddr::V4(addr) => write!(w, "{addr}"),
360        IpAddr::V6(addr) => write!(w, "\"{addr}\""),
361    }
362}
363
364#[cfg(test)]
365mod test {
366    use crate::{
367        dkim::Signature, dmarc::Policy, ArcOutput, AuthenticationResults, DkimOutput, DkimResult,
368        DmarcOutput, DmarcResult, Error, IprevOutput, IprevResult, ReceivedSpf, SpfOutput,
369        SpfResult,
370    };
371
372    #[test]
373    fn authentication_results() {
374        let mut auth_results = AuthenticationResults::new("mydomain.org");
375
376        for (expected_auth_results, dkim) in [
377            (
378                "dkim=pass header.d=example.org header.s=myselector",
379                DkimOutput {
380                    result: DkimResult::Pass,
381                    signature: (&Signature {
382                        d: "example.org".into(),
383                        s: "myselector".into(),
384                        ..Default::default()
385                    })
386                        .into(),
387                    report: None,
388                    is_atps: false,
389                },
390            ),
391            (
392                concat!(
393                    "dkim=fail (verification failed) header.d=example.org ",
394                    "header.s=myselector header.b=MTIzNDU2"
395                ),
396                DkimOutput {
397                    result: DkimResult::Fail(Error::FailedVerification),
398                    signature: (&Signature {
399                        d: "example.org".into(),
400                        s: "myselector".into(),
401                        b: b"123456".to_vec(),
402                        ..Default::default()
403                    })
404                        .into(),
405                    report: None,
406                    is_atps: false,
407                },
408            ),
409            (
410                concat!(
411                    "dkim-atps=temperror (dns error) header.d=atps.example.org ",
412                    "header.s=otherselctor header.b=YWJjZGVm header.from=jdoe@example.org"
413                ),
414                DkimOutput {
415                    result: DkimResult::TempError(Error::DnsError("".to_string())),
416                    signature: (&Signature {
417                        d: "atps.example.org".into(),
418                        s: "otherselctor".into(),
419                        b: b"abcdef".to_vec(),
420                        ..Default::default()
421                    })
422                        .into(),
423                    report: None,
424                    is_atps: true,
425                },
426            ),
427        ] {
428            auth_results = auth_results.with_dkim_results(&[dkim], "jdoe@example.org");
429            assert_eq!(
430                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
431                expected_auth_results
432            );
433        }
434
435        for (
436            expected_auth_results,
437            expected_received_spf,
438            result,
439            ip_addr,
440            receiver,
441            helo,
442            mail_from,
443        ) in [
444            (
445                concat!(
446                    "spf=pass (localhost: domain of jdoe@example.org designates 192.168.1.1 ",
447                    "as permitted sender) smtp.mailfrom=jdoe@example.org"
448                ),
449                concat!(
450                    "pass (localhost: domain of jdoe@example.org designates 192.168.1.1 as ",
451                    "permitted sender)\r\n\treceiver=localhost; client-ip=192.168.1.1; ",
452                    "envelope-from=\"jdoe@example.org\"; helo=example.org;"
453                ),
454                SpfResult::Pass,
455                "192.168.1.1".parse().unwrap(),
456                "localhost",
457                "example.org",
458                "jdoe@example.org",
459            ),
460            (
461                concat!(
462                    "spf=fail (mx.domain.org: domain of sender@otherdomain.org does not ",
463                    "designate a:b:c::f as permitted sender) smtp.mailfrom=sender@otherdomain.org"
464                ),
465                concat!(
466                    "fail (mx.domain.org: domain of sender@otherdomain.org does not designate ",
467                    "a:b:c::f as permitted sender)\r\n\treceiver=mx.domain.org; ",
468                    "client-ip=a:b:c::f; envelope-from=\"sender@otherdomain.org\"; ",
469                    "helo=otherdomain.org;"
470                ),
471                SpfResult::Fail,
472                "a:b:c::f".parse().unwrap(),
473                "mx.domain.org",
474                "otherdomain.org",
475                "sender@otherdomain.org",
476            ),
477            (
478                concat!(
479                    "spf=neutral (mx.domain.org: domain of postmaster@example.org reports neutral ",
480                    "for a:b:c::f) smtp.mailfrom=<>"
481                ),
482                concat!(
483                    "neutral (mx.domain.org: domain of postmaster@example.org reports neutral for ",
484                    "a:b:c::f)\r\n\treceiver=mx.domain.org; client-ip=a:b:c::f; ",
485                    "envelope-from=\"postmaster@example.org\"; helo=example.org;"
486                ),
487                SpfResult::Neutral,
488                "a:b:c::f".parse().unwrap(),
489                "mx.domain.org",
490                "example.org",
491                "",
492            ),
493        ] {
494            auth_results.hostname = receiver;
495            auth_results = auth_results.with_spf_mailfrom_result(
496                &SpfOutput {
497                    result,
498                    domain: "".to_string(),
499                    report: None,
500                    explanation: None,
501                },
502                ip_addr,
503                mail_from,
504                helo,
505            );
506            let received_spf = ReceivedSpf::new(
507                &SpfOutput {
508                    result,
509                    domain: "".to_string(),
510                    report: None,
511                    explanation: None,
512                },
513                ip_addr,
514                helo,
515                mail_from,
516                receiver,
517            );
518            assert_eq!(
519                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
520                expected_auth_results
521            );
522            assert_eq!(received_spf.received_spf, expected_received_spf);
523        }
524
525        for (expected_auth_results, dmarc) in [
526            (
527                "dmarc=pass header.from=example.org policy.dmarc=none",
528                DmarcOutput {
529                    spf_result: DmarcResult::Pass,
530                    dkim_result: DmarcResult::None,
531                    domain: "example.org".to_string(),
532                    policy: Policy::None,
533                    record: None,
534                },
535            ),
536            (
537                "dmarc=fail (policy not aligned) header.from=example.com policy.dmarc=quarantine",
538                DmarcOutput {
539                    dkim_result: DmarcResult::Fail(Error::NotAligned),
540                    spf_result: DmarcResult::None,
541                    domain: "example.com".to_string(),
542                    policy: Policy::Quarantine,
543                    record: None,
544                },
545            ),
546        ] {
547            auth_results = auth_results.with_dmarc_result(&dmarc);
548            assert_eq!(
549                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
550                expected_auth_results
551            );
552        }
553
554        for (expected_auth_results, arc, remote_ip) in [
555            (
556                "arc=pass smtp.remote-ip=192.127.9.2",
557                DkimResult::Pass,
558                "192.127.9.2".parse().unwrap(),
559            ),
560            (
561                "arc=neutral (body hash did not verify) smtp.remote-ip=\"1:2:3::a\"",
562                DkimResult::Neutral(Error::FailedBodyHashMatch),
563                "1:2:3::a".parse().unwrap(),
564            ),
565        ] {
566            auth_results = auth_results.with_arc_result(
567                &ArcOutput {
568                    result: arc,
569                    set: vec![],
570                },
571                remote_ip,
572            );
573            assert_eq!(
574                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
575                expected_auth_results
576            );
577        }
578
579        for (expected_auth_results, iprev, remote_ip) in [
580            (
581                "iprev=pass policy.iprev=192.127.9.2",
582                IprevOutput {
583                    result: IprevResult::Pass,
584                    ptr: None,
585                },
586                "192.127.9.2".parse().unwrap(),
587            ),
588            (
589                "iprev=fail (policy not aligned) policy.iprev=\"1:2:3::a\"",
590                IprevOutput {
591                    result: IprevResult::Fail(Error::NotAligned),
592                    ptr: None,
593                },
594                "1:2:3::a".parse().unwrap(),
595            ),
596        ] {
597            auth_results = auth_results.with_iprev_result(&iprev, remote_ip);
598            assert_eq!(
599                auth_results.auth_results.rsplit_once(';').unwrap().1.trim(),
600                expected_auth_results
601            );
602        }
603    }
604}