vsmtp-rule-engine 2.0.1-rc.4

Next-gen MTA. Secured, Faster and Greener
Documentation
/*
 * vSMTP mail transfer agent
 * Copyright (C) 2022 viridIT SAS
 *
 * This program is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see https://www.gnu.org/licenses/.
 *
*/

use crate::{
    api::{Context, EngineResult, Message, Server},
    get_global,
};
use rhai::plugin::{
    mem, Dynamic, FnAccess, FnNamespace, ImmutableString, Module, NativeCallContext,
    PluginFunction, RhaiResult, TypeId,
};
use vsmtp_auth::dkim::{
    sign, verify, Canonicalization, PrivateKey, PublicKey, Signature, VerificationResult,
    VerifierError,
};
use vsmtp_mail_parser::MessageBody;

pub use dkim::*;

/// Generate and verify DKIM signatures.
/// Implementation of RFC 6376. (<https://www.rfc-editor.org/rfc/rfc6376.html>)
#[rhai::plugin::export_module]
mod dkim {
    /// Has the `ctx()` a DKIM signature verification result ?
    #[allow(clippy::needless_pass_by_value)]
    #[rhai_fn(name = "has_result", return_raw)]
    pub fn has_result(ncc: NativeCallContext) -> EngineResult<bool> {
        super::Impl::has_dkim_result(&get_global!(ncc, ctx)?)
    }

    /// Return the DKIM signature verification result in the `ctx()` or
    /// an error if no result is found.
    #[allow(clippy::needless_pass_by_value)]
    #[rhai_fn(name = "result", return_raw)]
    pub fn result(ncc: NativeCallContext) -> EngineResult<rhai::Map> {
        super::Impl::dkim_result(&get_global!(ncc, ctx)?)
    }

    /// Store the result produced by the DKIM signature verification in the `ctx()`.
    ///
    /// # Error
    /// * The `status` field is missing in the DKIM verification results.
    #[allow(clippy::needless_pass_by_value)]
    #[rhai_fn(return_raw)]
    pub fn store(ncc: NativeCallContext, result: rhai::Map) -> EngineResult<()> {
        super::Impl::store(&get_global!(ncc, ctx)?, &result)
    }

    /// Get the list of DKIM private keys associated with this sdid
    #[allow(clippy::needless_pass_by_value)]
    #[rhai_fn(return_raw)]
    pub fn get_private_keys(ncc: NativeCallContext, sdid: &str) -> EngineResult<rhai::Array> {
        let server = get_global!(ncc, srv)?;
        let r#virtual = server
            .config
            .server
            .r#virtual
            .get(sdid)
            .and_then(|r#virtual| r#virtual.dkim.as_ref())
            .map(|dkim| {
                dkim.private_key
                    .iter()
                    .map(|key| rhai::Dynamic::from(key.inner.clone()))
                    .collect::<Vec<_>>()
            });

        Ok(r#virtual.unwrap_or_default())
    }

    /// return the `sdid` property of the [`Signature`]
    #[rhai_fn(global, get = "sdid", pure)]
    pub fn sdid(signature: &mut Signature) -> String {
        signature.sdid.clone()
    }

    /// return the `auid` property of the [`Signature`]
    #[rhai_fn(global, get = "auid", pure)]
    pub fn auid(signature: &mut Signature) -> String {
        signature.auid.clone()
    }

    /// Operate the hashing of the `message`'s headers and body, and compare the result with the
    /// `signature` and `key` data.
    ///
    /// # Examples
    ///
    /// ```
    /// // The message received.
    /// let msg = r#"
    /// Received: from github.com (hubbernetes-node-54a15d2.ash1-iad.github.net [10.56.202.84])
    /// 	by smtp.github.com (Postfix) with ESMTPA id 19FB45E0B6B
    /// 	for <mlala@negabit.com>; Wed, 26 Oct 2022 14:30:51 -0700 (PDT)
    /// DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=github.com;
    /// 	s=pf2014; t=1666819851;
    /// 	bh=7gTTczemS/Aahap1SpEnunm4pAPNuUIg7fUzwEx0QUA=;
    /// 	h=Date:From:To:Subject:From;
    /// 	b=eAufMk7uj4R+bO5Nr4DymffdGdbrJNza1+eykatgZED6tBBcMidkMiLSnP8FyVCS9
    /// 	 /GSlXME6/YffAXg4JEBr2lN3PuLIf94S86U3VckuoQQQe1LPtHlnGW5ZwJgi6DjrzT
    /// 	 klht/6Pn1w3a2jdNSDccWhk5qlSOQX9JKnE7UD58=
    /// Date: Wed, 26 Oct 2022 14:30:51 -0700
    /// From: Mathieu Lala <noreply@github.com>
    /// To: mlala@negabit.com
    /// Message-ID: <viridIT/vSMTP/push/refs/heads/test/rule-engine/000000-c6459a@github.com>
    /// Subject: [viridIT/vSMTP] c6459a: test: add test on message
    /// Mime-Version: 1.0
    /// Content-Type: text/plain;
    ///  charset=UTF-8
    /// Content-Transfer-Encoding: 7bit
    /// Approved: =?UTF-8?Q?hello_there_=F0=9F=91=8B?=
    /// X-GitHub-Recipient-Address: mlala@negabit.com
    /// X-Auto-Response-Suppress: All
    ///
    ///   Branch: refs/heads/test/rule-engine
    ///   Home:   https://github.com/viridIT/vSMTP
    ///   Commit: c6459a4946395ba90182ce7181bdbc327994c038
    ///       https://github.com/viridIT/vSMTP/commit/c6459a4946395ba90182ce7181bdbc327994c038
    ///   Author: Mathieu Lala <m.lala@viridit.com>
    ///   Date:   2022-10-26 (Wed, 26 Oct 2022)
    ///
    ///   Changed paths:
    ///     M src/vsmtp/vsmtp-rule-engine/src/api/message.rs
    ///     M src/vsmtp/vsmtp-rule-engine/src/lib.rs
    ///     M src/vsmtp/vsmtp-test/src/vsl.rs
    ///
    ///   Log Message:
    ///   -----------
    ///   test: add test on message
    ///
    ///
    /// "#;
    /// # let msg = vsmtp_mail_parser::MessageBody::try_from(msg[1..].replace("\n", "\r\n").as_str()).unwrap();
    ///
    /// # let states = vsmtp_test::vsl::run_with_msg(
    /// #    |builder| Ok(builder.add_root_filter_rules(r#"
    /// #{
    ///     preq: [
    ///         rule "verify dkim" || {
    ///             dkim::verify();
    ///
    ///             // The dkim header should indicate a pass.
    ///             if !msg::get_header("Authentication-Results").contains("dkim=pass") {
    ///               return state::deny();
    ///             }
    ///
    ///             // the result of dkim verification is cached, so this call will
    ///             // not recompute the signature and recreate a header.
    ///             dkim::verify();
    ///
    ///             // FIXME: should be one.
    ///             if msg::count_header("Authentication-Results") != 2 {
    ///               return state::deny();
    ///             }
    ///
    ///             state::accept()
    ///         }
    ///    ]
    ///  }
    /// # "#)?.build()), Some(msg));
    /// # use vsmtp_common::{status::Status, CodeID};
    /// # use vsmtp_rule_engine::ExecutionStage;
    /// # assert_eq!(states[&ExecutionStage::PreQ].2, Status::Accept(either::Left(CodeID::Ok)));
    /// ```
    ///
    /// Changing the header `Subject` will result in a dkim verification failure.
    ///
    /// ```
    /// // The message received.
    /// let msg = r#"
    /// Received: from github.com (hubbernetes-node-54a15d2.ash1-iad.github.net [10.56.202.84])
    /// 	by smtp.github.com (Postfix) with ESMTPA id 19FB45E0B6B
    /// 	for <mlala@negabit.com>; Wed, 26 Oct 2022 14:30:51 -0700 (PDT)
    /// DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=github.com;
    /// 	s=pf2014; t=1666819851;
    /// 	bh=7gTTczemS/Aahap1SpEnunm4pAPNuUIg7fUzwEx0QUA=;
    /// 	h=Date:From:To:Subject:From;
    /// 	b=eAufMk7uj4R+bO5Nr4DymffdGdbrJNza1+eykatgZED6tBBcMidkMiLSnP8FyVCS9
    /// 	 /GSlXME6/YffAXg4JEBr2lN3PuLIf94S86U3VckuoQQQe1LPtHlnGW5ZwJgi6DjrzT
    /// 	 klht/6Pn1w3a2jdNSDccWhk5qlSOQX9JKnE7UD58=
    /// Date: Wed, 26 Oct 2022 14:30:51 -0700
    /// From: Mathieu Lala <noreply@github.com>
    /// To: mlala@negabit.com
    /// Message-ID: <viridIT/vSMTP/push/refs/heads/test/rule-engine/000000-c6459a@github.com>
    /// Subject: Changing the header produce an invalid dkim verification
    /// Mime-Version: 1.0
    /// Content-Type: text/plain;
    ///  charset=UTF-8
    /// Content-Transfer-Encoding: 7bit
    /// Approved: =?UTF-8?Q?hello_there_=F0=9F=91=8B?=
    /// X-GitHub-Recipient-Address: mlala@negabit.com
    /// X-Auto-Response-Suppress: All
    ///
    ///   Branch: refs/heads/test/rule-engine
    ///   Home:   https://github.com/viridIT/vSMTP
    ///   Commit: c6459a4946395ba90182ce7181bdbc327994c038
    ///       https://github.com/viridIT/vSMTP/commit/c6459a4946395ba90182ce7181bdbc327994c038
    ///   Author: Mathieu Lala <m.lala@viridit.com>
    ///   Date:   2022-10-26 (Wed, 26 Oct 2022)
    ///
    ///   Changed paths:
    ///     M src/vsmtp/vsmtp-rule-engine/src/api/message.rs
    ///     M src/vsmtp/vsmtp-rule-engine/src/lib.rs
    ///     M src/vsmtp/vsmtp-test/src/vsl.rs
    ///
    ///   Log Message:
    ///   -----------
    ///   test: add test on message
    ///
    ///
    /// "#;
    /// # let msg = vsmtp_mail_parser::MessageBody::try_from(msg[1..].replace("\n", "\r\n").as_str()).unwrap();
    ///
    /// let rules = r#"#{
    ///     preq: [
    ///         rule "verify dkim" || {
    ///             dkim::verify();
    ///
    ///             if !msg::get_header("Authentication-Results").contains("dkim=fail") {
    ///               return state::deny();
    ///             }
    ///
    ///             state::accept();
    ///         }
    ///     ]
    /// }"#;
    ///
    /// # let states = vsmtp_test::vsl::run_with_msg(
    /// #   |builder| Ok(builder.add_root_filter_rules(rules)?.build()), Some(msg)
    /// # );
    /// # use vsmtp_common::{status::Status, CodeID};
    /// # use vsmtp_rule_engine::ExecutionStage;
    /// # assert_eq!(states[&ExecutionStage::PreQ].2, Status::Accept(either::Left(CodeID::Ok)));
    /// ```
    #[allow(clippy::needless_pass_by_value)]
    #[rhai_fn(return_raw)]
    pub fn verify(ncc: NativeCallContext) -> EngineResult<rhai::Map> {
        let ctx = get_global!(ncc, ctx)?;
        let msg = get_global!(ncc, msg)?;
        let result = super::Impl::verify_inner(
            &ctx,
            &msg,
            &get_global!(ncc, srv)?,
            5,
            // the dns query may result in multiple public key, the registry with invalid format are ignored.
            // among ["first_one", "cycle"]
            "cycle",
            // is the `expire_time` of the signature over `now +/- epsilon` (as seconds)
            100,
        )?;

        let header_value = format!(
            r#"{};
 dkim={}`"#,
            crate::api::utils::get_root_domain(vsl_guard_ok!(ctx.read()).server_name()),
            result
                .get("status")
                .map(std::string::ToString::to_string)
                .unwrap_or_default()
        );

        crate::api::message::Impl::prepend_header(&msg, "Authentication-Results", &header_value)?;

        Ok(result)
    }

    /// Produce a `DKIM-Signature` header.
    ///
    /// # Args
    ///
    /// * `selector` - the DNS selector to expose the public key & for the verifier
    /// * `private_key` - the private key to sign the mail,
    ///     associated with the public key in the `selector._domainkey.sdid` DNS record
    /// * `headers_field` - list of headers to sign
    /// * `canonicalization` - the canonicalization algorithm to use (ex: "simple/relaxed")
    ///
    /// # Effective smtp stage
    ///
    /// `preq` and onwards.
    ///
    /// # Example
    ///
    /// ```text
    /// #{
    ///   preq: [
    ///     action "sign dkim" || {
    ///       dkim::sign("2022-09", private_key, ["From", "To", "Date", "Subject", "From"], "simple/relaxed");
    ///     },
    ///   ]
    /// }
    /// ```
    #[allow(clippy::needless_pass_by_value)]
    #[rhai_fn(name = "sign", return_raw)]
    pub fn sign(
        ncc: NativeCallContext,
        selector: &str,
        private_key: std::sync::Arc<PrivateKey>,
        headers_field: rhai::Array,
        canonicalization: &str,
    ) -> EngineResult<()> {
        let signature = vsl_generic_ok!(super::Impl::generate_signature(
            &*vsl_guard_ok!(get_global!(ncc, msg)?.read()),
            vsl_guard_ok!(get_global!(ncc, ctx)?.read()).server_name(),
            selector,
            &private_key,
            &headers_field,
            canonicalization,
        ));

        crate::api::message::prepend_header(ncc, "DKIM-Signature", &signature)
    }

    /// Produce a `DKIM-Signature` header.
    /// Uses the "From", "To", "Date" and "Subject" headers to sign with the simple/relaxed policy.
    ///
    /// # Args
    ///
    /// * `selector` - the DNS selector to expose the public key & for the verifier
    /// * `private_key` - the private key to sign the mail,
    ///     associated with the public key in the `selector._domainkey.sdid` DNS record
    ///
    /// # Effective smtp stage
    ///
    /// `preq` and onwards.
    ///
    /// # Example
    ///
    /// ```text
    /// #{
    ///   preq: [
    ///     action "sign dkim" || {
    ///       dkim::sign("2022-09", private_key);
    ///     },
    ///   ]
    /// }
    /// ```
    #[rhai_fn(name = "sign", return_raw)]
    #[allow(clippy::needless_pass_by_value)]
    pub fn sign_with_default_headers_and_policy(
        ncc: NativeCallContext,
        selector: &str,
        private_key: std::sync::Arc<PrivateKey>,
    ) -> EngineResult<()> {
        sign(
            ncc,
            selector,
            private_key,
            ["From", "To", "Date", "Subject", "From"]
                .into_iter()
                .map(rhai::Dynamic::from)
                .collect::<rhai::Array>(),
            "simple/relaxed",
        )
    }
}

///
#[derive(Debug)]
pub struct DnsError(trust_dns_resolver::error::ResolveError);

impl Default for DnsError {
    fn default() -> Self {
        Self(trust_dns_resolver::error::ResolveError::from(
            trust_dns_resolver::error::ResolveErrorKind::Message("`default` invoked"),
        ))
    }
}

impl std::fmt::Display for DnsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

///
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, strum::EnumMessage, strum::EnumIter, thiserror::Error)]
pub enum DkimErrors {
    ///
    #[strum(message = "neutral", detailed_message = "signature_parsing_failed")]
    #[error("the parsing of the signature failed: `{inner}`")]
    SignatureParsingFailed {
        ///
        inner: <Signature as std::str::FromStr>::Err,
    },
    ///
    #[strum(message = "neutral", detailed_message = "key_parsing_failed")]
    #[error("the parsing of the public key failed: `{inner}`")]
    KeyParsingFailed {
        ///
        inner: <PublicKey as std::str::FromStr>::Err,
    },
    ///
    #[strum(message = "neutral", detailed_message = "invalid_argument")]
    #[error("invalid argument: `{inner}`")]
    InvalidArgument {
        ///
        inner: String,
    },
    ///
    #[strum(message = "temperror", detailed_message = "temp_dns_error")]
    #[error("temporary dns error: `{inner}`")]
    TempDnsError {
        ///
        inner: DnsError,
    },
    ///
    #[strum(message = "permerror", detailed_message = "perm_dns_error")]
    #[error("permanent dns error: `{inner}`")]
    PermDnsError {
        ///
        inner: DnsError,
    },
    ///
    #[strum(message = "fail", detailed_message = "signature_mismatch")]
    #[error("the signature does not match: `{inner}`")]
    SignatureMismatch {
        ///
        inner: VerifierError,
    },
}

impl From<DkimErrors> for Box<rhai::EvalAltResult> {
    fn from(this: DkimErrors) -> Self {
        Self::new(rhai::EvalAltResult::ErrorRuntime(
            rhai::Dynamic::from_map(rhai::Map::from_iter([
                (
                    "type".into(),
                    strum::EnumMessage::get_detailed_message(&this)
                        .expect("`DkimErrors` must have a `detailed message` for each variant")
                        .to_string()
                        .into(),
                ),
                ("inner".into(), rhai::Dynamic::from(this.to_string())),
            ])),
            rhai::Position::NONE,
        ))
    }
}

///
pub struct Impl;

impl Impl {
    /// # Result
    /// # Errors
    pub fn has_dkim_result(ctx: &Context) -> EngineResult<bool> {
        Ok(vsl_guard_ok!(ctx.read())
            .dkim()
            .map_err::<Box<rhai::EvalAltResult>, _>(|_| "bad state".into())?
            .is_some())
    }

    /// Return the DKIM signature verification result in the `ctx()` or
    /// an error if no result is found.
    /// # Result
    /// # Errors
    pub fn dkim_result(ctx: &Context) -> EngineResult<rhai::Map> {
        vsl_guard_ok!(ctx.read())
            .dkim()
            .map_err::<Box<rhai::EvalAltResult>, _>(|_| "bad state".into())?
            .map_or_else(
                || Err("no `dkim_result` available".into()),
                |dkim_result| {
                    Ok(rhai::Map::from_iter([(
                        "status".into(),
                        dkim_result.status.clone().into(),
                    )]))
                },
            )
    }

    ///
    #[tracing::instrument(ret, err)]
    pub fn parse_signature(input: &str) -> Result<Signature, DkimErrors> {
        <Signature as std::str::FromStr>::from_str(input)
            .map_err(|inner| DkimErrors::SignatureParsingFailed { inner })
    }

    #[tracing::instrument(ret, err)]
    fn verify(
        message: &MessageBody,
        signature: &Signature,
        key: &PublicKey,
    ) -> Result<(), DkimErrors> {
        verify(signature, message.inner(), key)
            .map_err(|inner| DkimErrors::SignatureMismatch { inner })
    }

    #[tracing::instrument(skip(server), ret, err)]
    fn get_public_key(
        server: &Server,
        signature: &Signature,
        on_multiple_key_records: &str,
    ) -> Result<Vec<PublicKey>, DkimErrors> {
        const VALID_POLICY: [&str; 2] = ["first", "cycle"];
        if !VALID_POLICY.contains(&on_multiple_key_records) {
            return Err(DkimErrors::InvalidArgument {
                inner: format!(
                    "expected values in `[first, cycle]` but got `{on_multiple_key_records}`",
                ),
            });
        }

        let resolver = server.resolvers.get_resolver_root();

        let txt_record =
            block_on!(resolver.txt_lookup(signature.get_dns_query())).map_err(|e| {
                use trust_dns_resolver::error::ResolveErrorKind;
                if matches!(
                    e.kind(),
                    ResolveErrorKind::Message(_)
                        | ResolveErrorKind::Msg(_)
                        | ResolveErrorKind::NoConnections
                        | ResolveErrorKind::NoRecordsFound { .. }
                ) {
                    DkimErrors::PermDnsError { inner: DnsError(e) }
                } else {
                    DkimErrors::TempDnsError { inner: DnsError(e) }
                }
            })?;

        let keys = txt_record
            .into_iter()
            .map(|i| <PublicKey as std::str::FromStr>::from_str(&i.to_string()));

        let keys = keys
            .collect::<Result<Vec<_>, <PublicKey as std::str::FromStr>::Err>>()
            .map_err(|inner| DkimErrors::KeyParsingFailed { inner })?;

        Ok(if on_multiple_key_records == "first" {
            keys.into_iter().next().map_or_else(Vec::new, |i| vec![i])
        } else {
            keys
        })
    }

    #[tracing::instrument(ret, err)]
    fn generate_signature(
        message: &MessageBody,
        sdid: &str,
        selector: &str,
        private_key: &PrivateKey,
        headers_field: &rhai::Array,
        canonicalization: &str,
    ) -> Result<String, DkimErrors> {
        let signature = sign(
            message.inner(),
            private_key,
            sdid.to_string(),
            selector.to_string(),
            <Canonicalization as std::str::FromStr>::from_str(canonicalization).map_err(|e| {
                DkimErrors::InvalidArgument {
                    inner: e.to_string(),
                }
            })?,
            headers_field.iter().map(ToString::to_string).collect(),
        )
        .map_err(|e| DkimErrors::InvalidArgument {
            inner: format!("the signature failed: `{e}`"),
        })?;

        Ok(signature.get_signature_value())
    }

    /// Store the result produced by the DKIM signature verification in the `ctx()`.
    ///
    /// # Errors
    /// * The `status` field is missing in the DKIM verification results.
    pub fn store(ctx: &Context, result: &rhai::Map) -> EngineResult<()> {
        let result = VerificationResult {
            status: result
                .get("status")
                .ok_or_else::<Box<rhai::EvalAltResult>, _>(|| {
                    "`status` is missing in DKIM verification result".into()
                })?
                .to_string(),
        };

        Ok(vsl_generic_ok!(vsl_guard_ok!(ctx.write()).set_dkim(result)))
    }

    /// Check dkim signatures, return the generated result if it already as been computed.
    ///
    /// # Result
    /// # Errors
    pub fn verify_inner(
        ctx: &Context,
        msg: &Message,
        srv: &Server,
        nbr_headers: usize,
        on_multiple_key_records: &str,
        expiration_epsilon: u64,
    ) -> EngineResult<rhai::Map> {
        if Self::has_dkim_result(ctx)? {
            Self::dkim_result(ctx)
        } else {
            let result = Self::verify_first_signature_or_error(
                msg,
                srv,
                nbr_headers,
                on_multiple_key_records,
                expiration_epsilon,
            )?;
            Self::store(ctx, &result)?;

            Ok(result)
        }
    }

    /// Verify and return the first valid signature.
    ///
    /// If no valid signature is found, then the function does NOT
    /// return an error, but a rhai map with an error status.
    ///
    /// # Return
    ///
    /// A rhai map with a status property, sdid & auid in case the verification
    /// was successful.
    ///
    /// # Errors
    ///
    /// * `get_header_untouched` failed.
    #[allow(clippy::cognitive_complexity)]
    fn verify_first_signature_or_error(
        msg: &Message,
        srv: &Server,
        nbr_headers: usize,
        on_multiple_key_records: &str,
        expiration_epsilon: u64,
    ) -> EngineResult<rhai::Map> {
        tracing::debug!(%nbr_headers, %on_multiple_key_records, %expiration_epsilon, "Verifying DKIM signature.");

        let mut last_error: Option<String> = None;

        let mut header = crate::api::message::Impl::get_header_untouched(msg, "DKIM-Signature")?;
        header.truncate(nbr_headers);

        for input in header {
            let signature = match Self::parse_signature(&input.to_string()) {
                Ok(signature) => signature,
                Err(error) => {
                    tracing::warn!(%error, "Failed to parse DKIM signature, continuing ...");
                    last_error = Some(Self::get_dkim_error_status(&error));
                    continue;
                }
            };

            if signature.has_expired(expiration_epsilon) {
                tracing::warn!("DKIM signature expired, continuing ...");
                continue;
            }

            // NOTE: for any reason, you can decide to ignore the signature
            // if signature... {
            //     continue;
            // }

            for key in &Self::get_public_key(srv, &signature, on_multiple_key_records)? {
                if let Err(error) = Self::verify(&*vsl_guard_ok!(msg.read()), &signature, key) {
                    tracing::warn!(%error, "DKIM signature verification failed");
                    last_error = Some(Self::get_dkim_error_status(&error));
                    continue;
                }

                tracing::debug!("DKIM signature successfully verified.");

                if key.has_debug_flag() {
                    tracing::warn!("DKIM signature contains `debug_flag`, continuing");
                    continue;
                }

                // header.b & header.a can be set optionally
                return Ok(rhai::Map::from_iter([
                    ("status".into(), "pass".into()),
                    ("sdid".into(), signature.sdid.into()),
                    ("auid".into(), signature.auid.into()),
                ]));
            }
        }

        tracing::warn!("no valid DKIM signature");

        Ok(rhai::Map::from_iter([(
            "status".into(),
            last_error.unwrap_or_else(|| "none".to_string()).into(),
        )]))
    }

    /// Get the dkim status from an error produced by this module.
    fn get_dkim_error_status(error: &DkimErrors) -> String {
        strum::EnumMessage::get_message(error)
            .expect("`DkimErrors` must have a `message` for each variant")
            .to_string()
    }
}