kumo_api_types/
shaping.rs

1use crate::egress_path::EgressPathConfig;
2#[cfg(feature = "lua")]
3use anyhow::Context;
4#[cfg(feature = "lua")]
5use config::any_err;
6#[cfg(feature = "lua")]
7use config::serialize_options;
8#[cfg(feature = "lua")]
9use dns_resolver::fully_qualify;
10#[cfg(feature = "lua")]
11use kumo_log_types::JsonLogRecord;
12#[cfg(feature = "lua")]
13use mailexchanger::MailExchanger;
14#[cfg(feature = "lua")]
15use mlua::prelude::LuaUserData;
16#[cfg(feature = "lua")]
17use mlua::{LuaSerdeExt, UserDataMethods};
18use ordermap::OrderMap;
19use serde::de::{SeqAccess, Visitor};
20use serde::{Deserialize, Deserializer, Serialize};
21use serde_with::formats::PreferOne;
22use serde_with::{serde_as, DeserializeAs, OneOrMany};
23#[cfg(feature = "lua")]
24use sha2::{Digest, Sha256};
25#[cfg(feature = "lua")]
26use std::collections::BTreeMap;
27use std::hash::{Hash, Hasher};
28use std::marker::PhantomData;
29#[cfg(feature = "lua")]
30use std::sync::Arc;
31use std::time::Duration;
32#[cfg(feature = "lua")]
33use throttle::LimitSpec;
34use throttle::ThrottleSpec;
35
36#[derive(Deserialize, Serialize, Debug, Clone)]
37#[serde(try_from = "String", into = "String")]
38pub struct Regex(fancy_regex::Regex);
39
40impl TryFrom<String> for Regex {
41    type Error = fancy_regex::Error;
42
43    fn try_from(s: String) -> fancy_regex::Result<Self> {
44        Ok(Self(fancy_regex::Regex::new(&s)?))
45    }
46}
47
48impl From<Regex> for String {
49    fn from(r: Regex) -> String {
50        r.0.as_str().to_string()
51    }
52}
53
54impl std::ops::Deref for Regex {
55    type Target = fancy_regex::Regex;
56    fn deref(&self) -> &Self::Target {
57        &self.0
58    }
59}
60
61impl std::hash::Hash for Regex {
62    fn hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
63        self.0.as_str().hash(hasher)
64    }
65}
66
67/// toml::Value is not Hash because it may contain floating
68/// point numbers, which are problematic from a Ord and Eq
69/// perspective. We're okay with skirting around that for
70/// our purposes here, so we implement our own hashable
71/// wrapper around the toml value.
72#[derive(Deserialize, Serialize, Debug, Clone)]
73#[serde(from = "toml::Value", into = "toml::Value")]
74pub struct HashableTomlValue {
75    value: toml::Value,
76}
77
78impl From<toml::Value> for HashableTomlValue {
79    fn from(value: toml::Value) -> Self {
80        Self { value }
81    }
82}
83
84impl From<HashableTomlValue> for toml::Value {
85    fn from(value: HashableTomlValue) -> toml::Value {
86        value.value
87    }
88}
89
90impl std::ops::Deref for HashableTomlValue {
91    type Target = toml::Value;
92    fn deref(&self) -> &toml::Value {
93        &self.value
94    }
95}
96
97fn hash_toml<H>(value: &toml::Value, h: &mut H)
98where
99    H: Hasher,
100{
101    match value {
102        toml::Value::Boolean(v) => v.hash(h),
103        toml::Value::Datetime(v) => {
104            if let Some(d) = &v.date {
105                d.year.hash(h);
106                d.month.hash(h);
107                d.day.hash(h);
108            }
109            if let Some(t) = &v.time {
110                t.hour.hash(h);
111                t.minute.hash(h);
112                t.second.hash(h);
113                t.nanosecond.hash(h);
114            }
115            if let Some(toml::value::Offset::Custom { minutes }) = &v.offset {
116                minutes.hash(h);
117            }
118        }
119        toml::Value::String(v) => v.hash(h),
120        toml::Value::Integer(v) => v.hash(h),
121        toml::Value::Float(v) => v.to_ne_bytes().hash(h),
122        toml::Value::Array(a) => {
123            for v in a.iter() {
124                hash_toml(v, h);
125            }
126        }
127        toml::Value::Table(m) => {
128            for (k, v) in m.iter() {
129                k.hash(h);
130                hash_toml(v, h);
131            }
132        }
133    }
134}
135
136impl Hash for HashableTomlValue {
137    fn hash<H>(&self, h: &mut H)
138    where
139        H: Hasher,
140    {
141        hash_toml(&self.value, h);
142    }
143}
144
145/// Represents an individual EgressPathConfig field name and value.
146/// It only allows deserializing from valid EgressPathConfig field + values.
147#[derive(Deserialize, Serialize, Debug, Clone, Hash)]
148#[serde(
149    try_from = "EgressPathConfigValueUnchecked",
150    into = "EgressPathConfigValueUnchecked"
151)]
152pub struct EgressPathConfigValue {
153    pub name: String,
154    pub value: HashableTomlValue,
155}
156
157/// This is the type that we actually use to deserialize EgressPathConfigValue items.
158/// It doesn't care about validity; it is used solely to tell serde what shape of
159/// data to expect.
160/// The validation is performed by the TryFrom impl that is used to convert to the
161/// checked form below.
162#[derive(Deserialize, Serialize, Debug, Clone)]
163pub struct EgressPathConfigValueUnchecked {
164    pub name: String,
165    pub value: toml::Value,
166}
167
168impl TryFrom<EgressPathConfigValueUnchecked> for EgressPathConfigValue {
169    type Error = anyhow::Error;
170    fn try_from(config: EgressPathConfigValueUnchecked) -> anyhow::Result<EgressPathConfigValue> {
171        let mut map = toml::map::Map::new();
172        map.insert(config.name.clone(), config.value.clone());
173        let table = toml::Value::Table(map);
174
175        // Attempt to deserialize as EgressPathConfig.
176        // If it fails, then the field name/value are invalid
177        EgressPathConfig::deserialize(table)?;
178
179        // If we reach this point, we can pass along the name/value
180        Ok(EgressPathConfigValue {
181            name: config.name,
182            value: HashableTomlValue {
183                value: config.value,
184            },
185        })
186    }
187}
188
189impl From<EgressPathConfigValue> for EgressPathConfigValueUnchecked {
190    fn from(config: EgressPathConfigValue) -> EgressPathConfigValueUnchecked {
191        EgressPathConfigValueUnchecked {
192            name: config.name,
193            value: config.value.value,
194        }
195    }
196}
197
198#[derive(Deserialize, Serialize, Debug, Clone, Hash)]
199pub enum Action {
200    Suspend,
201    SetConfig(EgressPathConfigValue),
202    SuspendTenant,
203    SuspendCampaign,
204    SetDomainConfig(EgressPathConfigValue),
205    Bounce,
206    BounceTenant,
207    BounceCampaign,
208}
209
210#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash, Default)]
211pub enum Trigger {
212    /// Trigger on the first match, immediately
213    #[default]
214    Immediate,
215    /// Trigger when a certain number of matches occur
216    /// over a certain time period.
217    Threshold(ThrottleSpec),
218}
219
220#[serde_as]
221#[derive(Deserialize, Serialize, Debug, Hash, Clone)]
222pub struct Rule {
223    #[serde(deserialize_with = "regex_string_or_array")]
224    pub regex: Vec<Regex>,
225
226    #[serde(deserialize_with = "one_or_many_action")]
227    pub action: Vec<Action>,
228
229    #[serde(default)]
230    pub trigger: Trigger,
231
232    #[serde(with = "duration_serde")]
233    pub duration: Duration,
234
235    #[serde(skip)]
236    pub was_rollup: bool,
237
238    /// if true, this rule can match kumomta internally generated
239    /// Response messages, otherwise, the rule will skip testing
240    /// against those.
241    #[serde(default)]
242    pub match_internal: bool,
243}
244
245impl Rule {
246    pub fn matches(&self, is_internal: bool, response: &str) -> bool {
247        if is_internal && !self.match_internal {
248            return false;
249        }
250        self.regex
251            .iter()
252            .any(|r| r.is_match(response).unwrap_or(false))
253    }
254
255    pub fn clone_and_set_rollup(&self) -> Self {
256        let mut result = self.clone();
257        result.was_rollup = true;
258        result
259    }
260}
261
262#[cfg(feature = "lua")]
263#[derive(Debug, Default)]
264struct ShapingInner {
265    by_site: OrderMap<String, PartialEntry>,
266    by_domain: OrderMap<String, PartialEntry>,
267    by_provider: OrderMap<String, ProviderEntry>,
268    warnings: Vec<String>,
269    errors: Vec<String>,
270    hash: String,
271}
272
273#[cfg(feature = "lua")]
274impl ShapingInner {
275    pub async fn get_egress_path_config(
276        &self,
277        domain: &str,
278        egress_source: &str,
279        site_name: &str,
280    ) -> PartialEntry {
281        let mut params = PartialEntry::default();
282
283        // Apply basic/default configuration
284        if let Some(default) = self.by_domain.get("default") {
285            params.merge_from(default.clone());
286        }
287
288        // Provider rules come next.
289        // They can only match valid domain names, so we'll
290        // skip processing them if we have something like our
291        // TSA http "domain name" here
292        let is_domain_name = dns_resolver::Name::from_str_relaxed(domain).is_ok();
293        if is_domain_name {
294            let mut prov_with_sources = vec![];
295
296            for prov in self.by_provider.values() {
297                if prov.domain_matches(domain).await {
298                    toml_table_merge_from(&mut params.params, &prov.params);
299                    prov.apply_provider_params_to(egress_source, &mut params.params);
300
301                    if !prov.sources.is_empty() {
302                        // Remember this matching provider, so that we
303                        // can apply any source rules after we've applied
304                        // any/all base provider rules for this domain
305                        prov_with_sources.push(prov);
306                    }
307                }
308            }
309
310            // Then Provider source rules
311            for prov in prov_with_sources {
312                if let Some(source) = prov.sources.get(egress_source) {
313                    toml_table_merge_from(&mut params.params, source);
314                    prov.apply_provider_params_to(egress_source, &mut params.params);
315                }
316            }
317        }
318
319        // Then site config
320        if let Some(by_site) = self.by_site.get(site_name) {
321            params.merge_from(by_site.clone());
322        }
323
324        // Then domain config
325        if let Some(by_domain) = self.by_domain.get(domain) {
326            params.merge_from(by_domain.clone());
327        }
328
329        // Then source config for the site
330        if let Some(by_site) = self.by_site.get(site_name) {
331            if let Some(source) = by_site.sources.get(egress_source) {
332                toml_table_merge_from(&mut params.params, source);
333            }
334        }
335
336        // Then source config for the domain
337        if let Some(by_domain) = self.by_domain.get(domain) {
338            if let Some(source) = by_domain.sources.get(egress_source) {
339                toml_table_merge_from(&mut params.params, source);
340            }
341        }
342
343        params
344    }
345
346    pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result<Vec<Rule>> {
347        use rfc5321::parser::ForwardPath;
348        // Extract the domain from the recipient.
349        let recipient = ForwardPath::try_from(
350            record
351                .recipient
352                .first()
353                .ok_or_else(|| anyhow::anyhow!("no recipients!?"))?
354                .as_str(),
355        )
356        .map_err(|err| anyhow::anyhow!("parsing record.recipient: {err}"))?;
357
358        let recipient = match recipient {
359            ForwardPath::Postmaster => {
360                // It doesn't make sense to apply automation on the
361                // local postmaster address, so we ignore this.
362                return Ok(vec![]);
363            }
364            ForwardPath::Path(path) => path.mailbox,
365        };
366        let domain = recipient.domain.to_string();
367
368        // Track events/outcomes by site.
369        let source = record.egress_source.as_deref().unwrap_or("unspecified");
370        // record.site is poorly named; it is really an identifier for the
371        // egress path. For matching purposes, we want just the site_name
372        // in the form produced by our MX resolution process.
373        // In an earlier incarnation of this logic, we would resolve the
374        // site_name for ourselves based on other data in the record,
375        // but that could lead to over-resolution of some names and
376        // yield surprising results.
377        // What we do here is extract the egress path decoration from
378        // record.site to arrive at something that looks like the
379        // mx site_name.
380        // NOTE: this is coupled with the logic in
381        // ReadyQueueManager::compute_queue_name
382        let site_name = record
383            .site
384            .trim_start_matches(&format!("{source}->"))
385            .trim_end_matches("@smtp_client")
386            .to_string();
387
388        Ok(self.match_rules_impl(record, &domain, &site_name).await)
389    }
390
391    pub async fn match_rules_impl(
392        &self,
393        record: &JsonLogRecord,
394        domain: &str,
395        site_name: &str,
396    ) -> Vec<Rule> {
397        let mut result = vec![];
398        let response = record.response.to_single_line();
399        tracing::trace!("Consider rules for {response}");
400
401        let is_internal = record.response.content.starts_with("KumoMTA internal: ");
402
403        if let Some(default) = self.by_domain.get("default") {
404            for rule in &default.automation {
405                tracing::trace!("Consider \"default\" rule {rule:?} for {response}");
406                if rule.matches(is_internal, &response) {
407                    // For automation under `default`, we always
408                    // assume that mx_rollup should be true.
409                    // If you somehow have a domain where that isn't
410                    // true, you should avoid using `default` for
411                    // automation.  Honestly, it's best to avoid
412                    // using `default` for automation.
413                    result.push(rule.clone_and_set_rollup());
414                }
415            }
416        }
417
418        for prov in self.by_provider.values() {
419            if prov.domain_matches(domain).await {
420                for rule in &prov.automation {
421                    tracing::trace!(
422                        "Consider provider \"{}\" rule {rule:?} for {response}",
423                        prov.provider_name
424                    );
425                    if rule.matches(is_internal, &response) {
426                        result.push(rule.clone());
427                    }
428                }
429            }
430        }
431
432        // Then site config
433        if let Some(by_site) = self.by_site.get(site_name) {
434            for rule in &by_site.automation {
435                tracing::trace!("Consider \"{site_name}\" rule {rule:?} for {response}");
436                if rule.matches(is_internal, &response) {
437                    result.push(rule.clone_and_set_rollup());
438                }
439            }
440        }
441
442        // Then domain config
443        if let Some(by_domain) = self.by_domain.get(domain) {
444            for rule in &by_domain.automation {
445                tracing::trace!("Consider \"{domain}\" rule {rule:?} for {response}");
446                if rule.matches(is_internal, &response) {
447                    result.push(rule.clone());
448                }
449            }
450        }
451
452        result
453    }
454}
455
456#[cfg(feature = "lua")]
457#[derive(Debug, Default, Clone, mlua::FromLua)]
458pub struct Shaping {
459    inner: Arc<ShapingInner>,
460}
461
462#[cfg(feature = "lua")]
463fn from_json<'a, T: Deserialize<'a>>(json: &'a str) -> anyhow::Result<T> {
464    let d = &mut serde_json::Deserializer::from_str(json);
465    Ok(serde_path_to_error::deserialize(d)?)
466}
467
468#[cfg(feature = "lua")]
469fn from_toml<'a, T: Deserialize<'a>>(toml: &'a str) -> anyhow::Result<T> {
470    let d = toml::Deserializer::parse(toml)?;
471    Ok(serde_path_to_error::deserialize(d)?)
472}
473
474#[cfg(feature = "lua")]
475#[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
476pub enum CheckLevel {
477    #[default]
478    Ignore,
479    Warn,
480    Error,
481}
482#[cfg(feature = "lua")]
483impl std::str::FromStr for CheckLevel {
484    type Err = String;
485
486    fn from_str(s: &str) -> Result<Self, String> {
487        if s.eq_ignore_ascii_case("ignore") {
488            Ok(Self::Ignore)
489        } else if s.eq_ignore_ascii_case("warn") {
490            Ok(Self::Warn)
491        } else if s.eq_ignore_ascii_case("error") {
492            Ok(Self::Error)
493        } else {
494            Err(format!(
495                "Expected one of `Ignore`, `Warn` or `Error`, got `{s}`"
496            ))
497        }
498    }
499}
500
501#[cfg(feature = "lua")]
502#[derive(Default)]
503struct Collector {
504    warnings: Vec<String>,
505    errors: Vec<String>,
506}
507
508#[cfg(feature = "lua")]
509impl Collector {
510    fn push<S: Into<String>>(&mut self, level: CheckLevel, msg: S) {
511        match level {
512            CheckLevel::Ignore => {}
513            CheckLevel::Warn => self.warnings.push(msg.into()),
514            CheckLevel::Error => self.errors.push(msg.into()),
515        }
516    }
517}
518
519#[cfg(feature = "lua")]
520#[derive(Debug, Clone, Deserialize)]
521#[serde_as]
522#[serde(deny_unknown_fields)]
523pub struct ShapingMergeOptions {
524    #[serde(default)]
525    pub provider_overlap: CheckLevel,
526    #[serde(default)]
527    pub dns_fail: CheckLevel,
528    #[serde(default)]
529    pub null_mx: CheckLevel,
530    #[serde(default)]
531    pub aliased_site: CheckLevel,
532    #[serde(default)]
533    pub skip_remote: bool,
534    #[serde(default)]
535    pub remote_load: CheckLevel,
536    #[serde(default)]
537    pub local_load: CheckLevel,
538    #[serde(default, with = "duration_serde")]
539    pub http_timeout: Option<Duration>,
540}
541
542#[cfg(feature = "lua")]
543impl Default for ShapingMergeOptions {
544    fn default() -> Self {
545        Self {
546            provider_overlap: CheckLevel::Ignore,
547            dns_fail: CheckLevel::Ignore,
548            null_mx: CheckLevel::Ignore,
549            aliased_site: CheckLevel::Ignore,
550            skip_remote: false,
551            remote_load: CheckLevel::Ignore,
552            local_load: CheckLevel::Error,
553            http_timeout: None,
554        }
555    }
556}
557
558#[cfg(feature = "lua")]
559impl Shaping {
560    async fn load_from_file(
561        path: &str,
562        options: &ShapingMergeOptions,
563        collector: &mut Collector,
564    ) -> anyhow::Result<ShapingFile> {
565        let (data, level): (String, CheckLevel) =
566            if path.starts_with("http://") || path.starts_with("https://") {
567                if options.skip_remote {
568                    collector.push(
569                        CheckLevel::Warn,
570                        format!("Ignoring {path} because skip_remote is set to true"),
571                    );
572                    return Ok(ShapingFile::default());
573                }
574
575                // To facilitate startup ordering races, and listing multiple subscription
576                // host replicas and allowing one or more of them to be temporarily down,
577                // we allow the http request to fail.
578                // We'll log the error message but consider it to be an empty map
579
580                async fn http_get(url: &str, timeout: Duration) -> anyhow::Result<String> {
581                    tokio::time::timeout(timeout, async {
582                        let response = reqwest::Client::builder()
583                            .timeout(timeout)
584                            .connect_timeout(timeout)
585                            .read_timeout(timeout)
586                            .build()?
587                            .get(url)
588                            .send()
589                            .await
590                            .with_context(|| format!("making HTTP request to {url}"))?;
591                        let status = response.status();
592                        if !status.is_success() {
593                            let err_text = match response.text().await {
594                                Ok(text) => text,
595                                Err(err) => {
596                                    format!("Additional error {err:#} while reading response body")
597                                }
598                            };
599                            anyhow::bail!(
600                                "HTTP request to {url} failed: status {status} {reason} {err_text}",
601                                status = status.as_u16(),
602                                reason = status.canonical_reason().unwrap_or("")
603                            );
604                        }
605                        response
606                            .text()
607                            .await
608                            .with_context(|| format!("reading text from {url}"))
609                    })
610                    .await
611                    .with_context(|| format!("timeout making HTTP request to {url}"))?
612                }
613
614                let timeout = options.http_timeout.unwrap_or(Duration::from_secs(5));
615
616                match http_get(path, timeout).await {
617                    Ok(s) => (s, options.remote_load),
618                    Err(err) => {
619                        tracing::error!("{err:#}. Ignoring this shaping source for now");
620                        collector.push(
621                            options.remote_load,
622                            format!("remote shaping source {path} error: {err:#}"),
623                        );
624                        return Ok(ShapingFile::default());
625                    }
626                }
627            } else {
628                match std::fs::read_to_string(path)
629                    .with_context(|| format!("loading data from file {path}"))
630                {
631                    Err(err) => {
632                        collector.push(
633                            options.local_load,
634                            format!("local shaping source {path} error: {err:#}"),
635                        );
636                        return Ok(ShapingFile::default());
637                    }
638                    Ok(s) => (s, options.local_load),
639                }
640            };
641
642        if path.ends_with(".toml") {
643            from_toml(&data).with_context(|| format!("parsing toml from file {path}"))
644        } else if path.ends_with(".json") {
645            from_json(&data).with_context(|| format!("parsing json from file {path}"))
646        } else {
647            // Try parsing both ways and see which wins
648            let mut errors = vec![];
649            match from_toml(&data) {
650                Ok(s) => return Ok(s),
651                Err(err) => errors.push(format!("as toml: {err:#}")),
652            }
653            match from_json(&data) {
654                Ok(s) => return Ok(s),
655                Err(err) => errors.push(format!("as json: {err:#}")),
656            }
657
658            collector.push(level, format!("parsing {path}: {}", errors.join(", ")));
659            Ok(ShapingFile::default())
660        }
661    }
662
663    pub async fn merge_files(
664        files: &[String],
665        options: &ShapingMergeOptions,
666    ) -> anyhow::Result<Self> {
667        use futures_util::stream::FuturesUnordered;
668        use futures_util::StreamExt;
669
670        let mut collector = Collector::default();
671        let mut loaded = vec![];
672        for p in files {
673            loaded.push(Self::load_from_file(p, options, &mut collector).await?);
674        }
675
676        let mut by_site: OrderMap<String, PartialEntry> = OrderMap::new();
677        let mut by_domain: OrderMap<String, PartialEntry> = OrderMap::new();
678        let mut by_provider: OrderMap<String, ProviderEntry> = OrderMap::new();
679        let mut site_aliases: OrderMap<String, Vec<String>> = OrderMap::new();
680
681        // Pre-resolve domains. We don't interleave the resolution with
682        // the work below, because we want to ensure that the ordering
683        // is preserved
684        let mut mx = std::collections::HashMap::new();
685        let mut lookups = FuturesUnordered::new();
686        for item in &loaded {
687            for (domain, partial) in &item.domains {
688                if partial.mx_rollup {
689                    let domain = domain.to_string();
690                    lookups.push(tokio::spawn(async move {
691                        let mx_result = MailExchanger::resolve(&domain).await;
692                        (domain, mx_result)
693                    }));
694                }
695            }
696        }
697
698        while let Some(Ok((domain, result))) = lookups.next().await {
699            mx.insert(domain, result);
700        }
701
702        for mut item in loaded {
703            if let Some(mut partial) = item.default.take() {
704                let domain = "default";
705                partial.domain_name.replace(domain.to_string());
706                match by_domain.get_mut(domain) {
707                    Some(existing) => {
708                        existing.merge_from(partial);
709                    }
710                    None => {
711                        by_domain.insert(domain.to_string(), partial);
712                    }
713                }
714            }
715
716            for (domain, mut partial) in item.domains {
717                partial.domain_name.replace(domain.clone());
718
719                if let Ok(name) = fully_qualify(&domain) {
720                    if name.num_labels() == 1 {
721                        collector.push(
722                            CheckLevel::Warn,
723                            format!(
724                                "Entry for domain '{domain}' consists of a \
725                                 single DNS label. Domain names in TOML sections \
726                                 need to be quoted like '[\"{domain}.com\"]` otherwise \
727                                 the '.' will create a nested table rather than being \
728                                 added to the domain name."
729                            ),
730                        );
731                    }
732                }
733
734                #[cfg(test)]
735                if partial._treat_domain_name_as_site_name {
736                    match by_site.get_mut(&domain) {
737                        Some(existing) => {
738                            existing.merge_from(partial);
739                        }
740                        None => {
741                            by_site.insert(domain.to_string(), partial);
742                        }
743                    }
744                    continue;
745                }
746                if partial.mx_rollup {
747                    let mx = match mx.get(&domain) {
748                        Some(Ok(mx)) => mx,
749                        Some(Err(err)) => {
750                            collector.push(
751                                options.dns_fail,
752                                format!(
753                                    "error resolving MX for {domain}: {err:#}. \
754                                 Ignoring the shaping config for that domain."
755                                ),
756                            );
757                            continue;
758                        }
759                        None => {
760                            collector.push(
761                                options.dns_fail,
762                                format!(
763                                "We didn't try to resolve the MX for {domain} for some reason!?. \
764                                 Ignoring the shaping config for that domain."
765                            ),
766                            );
767                            continue;
768                        }
769                    };
770
771                    if mx.site_name.is_empty() {
772                        collector.push(
773                            options.null_mx,
774                            format!(
775                            "domain {domain} has a NULL MX and cannot be used with mx_rollup=true. \
776                             Ignoring the shaping config for that domain."),
777                        );
778                        continue;
779                    }
780
781                    site_aliases
782                        .entry(mx.site_name.to_string())
783                        .or_default()
784                        .push(domain.to_string());
785
786                    match by_site.get_mut(&mx.site_name) {
787                        Some(existing) => {
788                            existing.merge_from(partial);
789                        }
790                        None => {
791                            by_site.insert(mx.site_name.clone(), partial);
792                        }
793                    }
794                } else {
795                    match by_domain.get_mut(&domain) {
796                        Some(existing) => {
797                            existing.merge_from(partial);
798                        }
799                        None => {
800                            by_domain.insert(domain, partial);
801                        }
802                    }
803                }
804            }
805
806            for (provider, mut prov) in item.provider {
807                prov.provider_name = provider.to_string();
808                match by_provider.get_mut(&provider) {
809                    Some(existing) => {
810                        existing.merge_from(prov);
811                    }
812                    None => {
813                        by_provider.insert(provider.to_string(), prov);
814                    }
815                }
816            }
817        }
818
819        for (site, partial) in &by_site {
820            partial
821                .clone()
822                .finish()
823                .with_context(|| format!("site: {site}"))?;
824        }
825
826        for (domain, partial) in &by_domain {
827            partial
828                .clone()
829                .finish()
830                .with_context(|| format!("domain: {domain}"))?;
831        }
832
833        for (provider, prov) in &by_provider {
834            prov.finish_params()
835                .with_context(|| format!("provider: {provider}"))?;
836        }
837
838        if options.aliased_site != CheckLevel::Ignore {
839            for (site, aliases) in site_aliases {
840                if aliases.len() > 1 {
841                    collector.push(
842                        options.aliased_site,
843                        format!(
844                            "multiple domain blocks alias to the same site: {site}: {}",
845                            aliases.join(", ")
846                        ),
847                    );
848                }
849            }
850        }
851
852        if options.provider_overlap != CheckLevel::Ignore {
853            for domain in mx.keys() {
854                let mut matching_providers = vec![];
855                for (prov_name, prov) in &by_provider {
856                    if prov.domain_matches(domain).await {
857                        matching_providers.push(prov_name.to_string());
858                    }
859                }
860                if !matching_providers.is_empty() {
861                    collector.push(
862                        options.provider_overlap,
863                        format!(
864                            "domain {domain} is also matched by provider(s): {}",
865                            matching_providers.join(", ")
866                        ),
867                    );
868                }
869            }
870        }
871
872        let mut ctx = Sha256::new();
873        ctx.update("by_site");
874        for (site, entry) in &by_site {
875            ctx.update(site);
876            entry.hash_into(&mut ctx);
877        }
878        ctx.update("by_domain");
879        for (domain, entry) in &by_domain {
880            ctx.update(domain);
881            entry.hash_into(&mut ctx);
882        }
883        ctx.update("by_provider");
884        for (provider, prov) in &by_provider {
885            ctx.update(provider);
886            prov.hash_into(&mut ctx);
887        }
888        ctx.update("warnings");
889        for warn in &collector.warnings {
890            ctx.update(warn);
891        }
892        ctx.update("errors");
893        for err in &collector.errors {
894            ctx.update(err);
895        }
896        let hash = ctx.finalize();
897        let hash = data_encoding::HEXLOWER.encode(&hash);
898
899        Ok(Self {
900            inner: Arc::new(ShapingInner {
901                by_site,
902                by_domain,
903                by_provider,
904                warnings: collector.warnings,
905                errors: collector.errors,
906                hash,
907            }),
908        })
909    }
910
911    async fn get_egress_path_config(
912        &self,
913        domain: &str,
914        egress_source: &str,
915        site_name: &str,
916    ) -> PartialEntry {
917        self.inner
918            .get_egress_path_config(domain, egress_source, site_name)
919            .await
920    }
921
922    pub async fn get_egress_path_config_value(
923        &self,
924        domain: &str,
925        egress_source: &str,
926        site_name: &str,
927    ) -> anyhow::Result<serde_json::Value> {
928        let partial = self
929            .get_egress_path_config(domain, egress_source, site_name)
930            .await;
931        Ok(serde_json::to_value(&partial)?)
932    }
933
934    pub fn get_errors(&self) -> &[String] {
935        &self.inner.errors
936    }
937
938    pub fn get_warnings(&self) -> &[String] {
939        &self.inner.warnings
940    }
941
942    pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result<Vec<Rule>> {
943        self.inner.match_rules(record).await
944    }
945
946    pub fn get_referenced_sources(&self) -> BTreeMap<String, Vec<String>> {
947        let mut result = BTreeMap::new();
948
949        for (site_name, site) in &self.inner.by_site {
950            for source_name in site.sources.keys() {
951                result
952                    .entry(source_name.to_string())
953                    .or_insert(vec![])
954                    .push(format!("site:{site_name}"));
955            }
956        }
957        for (domain_name, domain) in &self.inner.by_domain {
958            for source_name in domain.sources.keys() {
959                result
960                    .entry(source_name.to_string())
961                    .or_insert(vec![])
962                    .push(format!("domain:{domain_name}"));
963            }
964        }
965
966        result
967    }
968
969    pub fn hash(&self) -> String {
970        self.inner.hash.clone()
971    }
972}
973
974#[cfg(feature = "lua")]
975impl LuaUserData for Shaping {
976    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
977        mod_memoize::Memoized::impl_memoize(methods);
978        methods.add_async_method(
979            "get_egress_path_config",
980            |lua, this, (domain, egress_source, site_name): (String, String, String)| async move {
981                let params = this
982                    .get_egress_path_config(&domain, &egress_source, &site_name)
983                    .await;
984                lua.to_value_with(&params.params, serialize_options())
985            },
986        );
987
988        methods.add_method("get_errors", move |_lua, this, ()| {
989            let errors: Vec<String> = this.get_errors().iter().map(|s| s.to_string()).collect();
990            Ok(errors)
991        });
992
993        methods.add_method("get_warnings", move |_lua, this, ()| {
994            let warnings: Vec<String> = this.get_warnings().iter().map(|s| s.to_string()).collect();
995            Ok(warnings)
996        });
997
998        methods.add_method("get_referenced_sources", move |_lua, this, ()| {
999            Ok(this.get_referenced_sources())
1000        });
1001
1002        methods.add_async_method("match_rules", |lua, this, record: mlua::Value| async move {
1003            let record: JsonLogRecord = lua.from_value(record)?;
1004            let rules = this.match_rules(&record).await.map_err(any_err)?;
1005            let mut result = vec![];
1006            for rule in rules {
1007                result.push(lua.to_value(&rule)?);
1008            }
1009            Ok(result)
1010        });
1011
1012        methods.add_method("hash", move |_, this, ()| Ok(this.hash()));
1013    }
1014}
1015
1016#[derive(Default, Debug)]
1017pub struct MergedEntry {
1018    pub params: EgressPathConfig,
1019    pub sources: OrderMap<String, EgressPathConfig>,
1020    pub automation: Vec<Rule>,
1021}
1022
1023#[cfg(feature = "lua")]
1024#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1025struct ShapingFile {
1026    pub default: Option<PartialEntry>,
1027    #[serde(flatten, default)]
1028    pub domains: OrderMap<String, PartialEntry>,
1029    #[serde(default)]
1030    pub provider: OrderMap<String, ProviderEntry>,
1031}
1032
1033#[cfg(feature = "lua")]
1034#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1035struct PartialEntry {
1036    #[serde(skip)]
1037    pub domain_name: Option<String>,
1038
1039    #[serde(flatten)]
1040    pub params: toml::Table,
1041
1042    #[serde(default = "default_true")]
1043    pub mx_rollup: bool,
1044
1045    // This is present to facilitate unit testing without requiring
1046    // DNS to resolve the site_name. When set to true, the domain_name
1047    // is considered to be the site_name for this entry.
1048    #[cfg(test)]
1049    #[serde(default)]
1050    pub _treat_domain_name_as_site_name: bool,
1051
1052    #[serde(default)]
1053    pub replace_base: bool,
1054
1055    #[serde(default)]
1056    pub automation: Vec<Rule>,
1057
1058    #[serde(default)]
1059    pub sources: OrderMap<String, toml::Table>,
1060}
1061
1062#[cfg(feature = "lua")]
1063#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1064pub struct ProviderEntry {
1065    #[serde(skip, default)]
1066    pub provider_name: String,
1067
1068    #[serde(default)]
1069    pub provider_connection_limit: Option<LimitSpec>,
1070
1071    #[serde(default)]
1072    pub provider_max_message_rate: Option<ThrottleSpec>,
1073
1074    #[serde(default, rename = "match")]
1075    pub matches: Vec<ProviderMatch>,
1076
1077    #[serde(default)]
1078    pub replace_base: bool,
1079
1080    #[serde(flatten)]
1081    pub params: toml::Table,
1082
1083    #[serde(default)]
1084    pub automation: Vec<Rule>,
1085
1086    #[serde(default)]
1087    pub sources: OrderMap<String, toml::Table>,
1088}
1089
1090#[cfg(feature = "lua")]
1091fn suffix_matches(candidate: &str, suffix: &str) -> bool {
1092    // Remove trailing dot from candidate, as our resolver tends
1093    // to leave the canonical dot on the input host name
1094    let candidate = candidate.strip_suffix(".").unwrap_or(candidate);
1095    // We DON'T handle case mismatches in this code.
1096    // We assume that the dns-resolver crate normalized the
1097    // names to lowercase and that the input suffix is lowercase as well.
1098    candidate.ends_with(suffix)
1099}
1100
1101#[cfg(feature = "lua")]
1102#[cfg(test)]
1103#[test]
1104fn test_suffix_matches() {
1105    assert!(suffix_matches("a", "a"));
1106    assert!(suffix_matches("foo.com", "foo.com"));
1107    assert!(!suffix_matches("foo.com", ".foo.com"));
1108    assert!(!suffix_matches("foo.com", "longer.com"));
1109    assert!(!suffix_matches("réputation.net", ".mx.microsoft"));
1110
1111    // We DON'T handle case mismatches in this code.
1112    // We assume that the dns-resolver crate normalized the
1113    // names to lowercase and that the input suffix is lowercase as well.
1114    assert!(!suffix_matches("foo.Com", ".com"));
1115    assert!(!suffix_matches("foo.Cam", ".com"));
1116}
1117
1118#[cfg(feature = "lua")]
1119fn host_matches(candidate: &str, name: &str) -> bool {
1120    // Remove trailing dot from candidate, as our resolver tends
1121    // to leave the canonical dot on the input host name
1122    let candidate = candidate.strip_suffix(".").unwrap_or(candidate);
1123    candidate == name
1124}
1125
1126#[cfg(feature = "lua")]
1127#[cfg(test)]
1128#[test]
1129fn test_host_matches() {
1130    assert!(host_matches("foo.com", "foo.com"));
1131    assert!(host_matches("foo.com.", "foo.com"));
1132    assert!(!host_matches("foo.com", "notfoo.com"));
1133}
1134
1135#[cfg(feature = "lua")]
1136impl ProviderEntry {
1137    async fn domain_matches(&self, domain: &str) -> bool {
1138        // We'd like to avoid doing DNS if we can do a simple suffix match,
1139        // so we bias to looking at those first
1140        let mut need_mx = false;
1141
1142        tracing::trace!(
1143            "ProviderEntry::domain_matches({domain}) vs {:?}",
1144            self.matches
1145        );
1146
1147        for rule in &self.matches {
1148            match rule {
1149                ProviderMatch::DomainSuffix(suffix) => {
1150                    if suffix_matches(domain, suffix) {
1151                        tracing::trace!("{domain} suffix matches {suffix}");
1152                        return true;
1153                    }
1154                }
1155                ProviderMatch::HostName(_) | ProviderMatch::MXSuffix(_) => {
1156                    need_mx = true;
1157                }
1158            }
1159        }
1160
1161        if !need_mx {
1162            return false;
1163        }
1164
1165        // Now we can consider DNS
1166        match MailExchanger::resolve(domain).await {
1167            Err(err) => {
1168                // Didn't resolve; could be legit, for example, could
1169                // be an internal or fake name that is handled via
1170                // smart host or custom routing, so we log what
1171                // happened as a trace rather than polluting the
1172                // logs about it
1173                tracing::trace!(
1174                    "Error resolving MX for {domain}: {err:#}. \
1175                    Provider {} match rules will be ignored",
1176                    self.provider_name
1177                );
1178                false
1179            }
1180            Ok(mx) => {
1181                tracing::trace!("Consider MXSuffix rules");
1182                for host in &mx.hosts {
1183                    let mut matched = false;
1184
1185                    for rule in &self.matches {
1186                        match rule {
1187                            ProviderMatch::MXSuffix(suffix) => {
1188                                // For a given MX suffix rule, all hosts must match
1189                                // it for it to be valid. This is so that we don't
1190                                // falsely lump a vanity domain that blends providers
1191                                // together.
1192                                tracing::trace!("suffix={suffix} vs host {host}");
1193                                if suffix_matches(host, suffix) {
1194                                    matched = true;
1195                                    break;
1196                                }
1197                            }
1198                            ProviderMatch::HostName(name) => {
1199                                if host_matches(host, name) {
1200                                    matched = true;
1201                                    break;
1202                                }
1203                            }
1204                            ProviderMatch::DomainSuffix(_) => {}
1205                        }
1206                    }
1207
1208                    if !matched {
1209                        tracing::trace!("host didn't match any of these rules");
1210                        return false;
1211                    }
1212                }
1213
1214                true
1215            }
1216        }
1217    }
1218
1219    fn merge_from(&mut self, mut other: Self) {
1220        if other.replace_base {
1221            self.provider_connection_limit = other.provider_connection_limit;
1222            self.matches = other.matches;
1223            self.params = other.params;
1224            self.sources = other.sources;
1225            self.automation = other.automation;
1226        } else {
1227            if other.provider_connection_limit.is_some() {
1228                self.provider_connection_limit = other.provider_connection_limit;
1229            }
1230
1231            toml_table_merge_from(&mut self.params, &other.params);
1232
1233            for (source, tbl) in other.sources {
1234                match self.sources.get_mut(&source) {
1235                    Some(existing) => {
1236                        toml_table_merge_from(existing, &tbl);
1237                    }
1238                    None => {
1239                        self.sources.insert(source, tbl);
1240                    }
1241                }
1242            }
1243
1244            self.matches.append(&mut other.matches);
1245            self.automation.append(&mut other.automation);
1246        }
1247    }
1248
1249    fn apply_provider_params_to(&self, source: &str, target: &mut toml::Table) {
1250        let mut implied = toml::Table::new();
1251        implied.insert(
1252            "provider_name".to_string(),
1253            toml::Value::String(self.provider_name.to_string()),
1254        );
1255
1256        if let Some(limit) = &self.provider_connection_limit {
1257            let mut limits = toml::Table::new();
1258            limits.insert(
1259                format!("shaping-provider-{}-{source}-limit", self.provider_name),
1260                toml::Value::String(limit.to_string()),
1261            );
1262            implied.insert(
1263                "additional_connection_limits".to_string(),
1264                toml::Value::Table(limits),
1265            );
1266        }
1267        if let Some(rate) = &self.provider_max_message_rate {
1268            let rate = rate.as_string();
1269            let mut limits = toml::Table::new();
1270            limits.insert(
1271                format!("shaping-provider-{}-{source}-rate", self.provider_name),
1272                rate.into(),
1273            );
1274            implied.insert(
1275                "additional_message_rate_throttles".to_string(),
1276                toml::Value::Table(limits),
1277            );
1278        }
1279
1280        if let Some(rate) = target.remove("provider_source_selection_rate") {
1281            let mut limits = toml::Table::new();
1282            limits.insert(
1283                format!(
1284                    "shaping-provider-{}-{source}-selection-rate",
1285                    self.provider_name
1286                ),
1287                rate,
1288            );
1289            implied.insert(
1290                "additional_source_selection_rates".to_string(),
1291                toml::Value::Table(limits),
1292            );
1293        }
1294
1295        toml_table_merge_from(target, &implied);
1296    }
1297
1298    fn finish_params(&self) -> anyhow::Result<MergedEntry> {
1299        let provider_name = &self.provider_name;
1300
1301        let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1302            format!(
1303                "interpreting provider '{provider_name}' params {:#?} as EgressPathConfig",
1304                self.params
1305            )
1306        })?;
1307        let mut sources = OrderMap::new();
1308
1309        for (source, params) in &self.sources {
1310            let mut params = params.clone();
1311            // I don't really like this remove call. The issue is that we don't
1312            // have an alternative way to filter this out of the partial source
1313            // definition, and this provider_ option is not valid in the
1314            // EgressPathConfig struct itself; it is a shaping source-specific
1315            // addition to help with setting up shared throttles across providers
1316            params.remove("provider_source_selection_rate");
1317            sources.insert(
1318                source.clone(),
1319                EgressPathConfig::deserialize(params.clone()).with_context(|| {
1320                    format!("interpreting provider '{provider_name}' source '{source}' {params:#} as EgressPathConfig")
1321                })?,
1322            );
1323        }
1324
1325        Ok(MergedEntry {
1326            params,
1327            sources,
1328            automation: self.automation.clone(),
1329        })
1330    }
1331
1332    fn hash_into(&self, ctx: &mut Sha256) {
1333        ctx.update(&self.provider_name);
1334        ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1335    }
1336}
1337
1338#[cfg(feature = "lua")]
1339#[derive(Deserialize, Serialize, Debug, Clone)]
1340pub enum ProviderMatch {
1341    MXSuffix(String),
1342    DomainSuffix(String),
1343    HostName(String),
1344}
1345
1346#[cfg(feature = "lua")]
1347fn toml_table_merge_from(tbl: &mut toml::Table, source: &toml::Table) {
1348    // Limit merging to just the throttle related fields, as their purpose
1349    // is for creating broader scoped limits that cut across normal boundaries
1350    fn is_mergeable(name: &str) -> bool {
1351        match name {
1352            "additional_connection_limits"
1353            | "additional_message_rate_throttles"
1354            | "additional_source_selection_rates" => true,
1355            _ => false,
1356        }
1357    }
1358
1359    for (k, v) in source {
1360        match (tbl.get_mut(k), v.as_table()) {
1361            // Merge Table values together, rather than simply replacing them.
1362            (Some(toml::Value::Table(existing)), Some(v)) if is_mergeable(k) => {
1363                for (inner_k, inner_v) in v {
1364                    existing.insert(inner_k.clone(), inner_v.clone());
1365                }
1366            }
1367            _ => {
1368                tbl.insert(k.clone(), v.clone());
1369            }
1370        }
1371    }
1372}
1373
1374#[cfg(feature = "lua")]
1375impl PartialEntry {
1376    fn merge_from(&mut self, mut other: Self) {
1377        if other.replace_base {
1378            self.params = other.params;
1379            self.automation = other.automation;
1380            self.sources = other.sources;
1381        } else {
1382            toml_table_merge_from(&mut self.params, &other.params);
1383
1384            for (source, tbl) in other.sources {
1385                match self.sources.get_mut(&source) {
1386                    Some(existing) => {
1387                        toml_table_merge_from(existing, &tbl);
1388                    }
1389                    None => {
1390                        self.sources.insert(source, tbl);
1391                    }
1392                }
1393            }
1394
1395            self.automation.append(&mut other.automation);
1396        }
1397    }
1398
1399    fn finish(self) -> anyhow::Result<MergedEntry> {
1400        let domain = self.domain_name.unwrap_or_default();
1401
1402        let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1403            format!(
1404                "interpreting domain '{domain}' params {:#?} as EgressPathConfig",
1405                self.params
1406            )
1407        })?;
1408        let mut sources = OrderMap::new();
1409
1410        for (source, params) in self.sources {
1411            sources.insert(
1412                source.clone(),
1413                EgressPathConfig::deserialize(params.clone()).with_context(|| {
1414                    format!("interpreting domain '{domain}' source '{source}' {params:#} as EgressPathConfig")
1415                })?,
1416            );
1417        }
1418
1419        Ok(MergedEntry {
1420            params,
1421            sources,
1422            automation: self.automation,
1423        })
1424    }
1425
1426    fn hash_into(&self, ctx: &mut Sha256) {
1427        if let Some(name) = self.domain_name.as_ref() {
1428            ctx.update(name)
1429        }
1430        ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1431    }
1432}
1433
1434fn one_or_many<'de, T, D>(deserializer: D, expecting: &str) -> Result<Vec<T>, D::Error>
1435where
1436    T: Deserialize<'de>,
1437    D: Deserializer<'de>,
1438{
1439    let result: Result<Vec<T>, _> =
1440        OneOrMany::<serde_with::Same, PreferOne>::deserialize_as(deserializer);
1441    match result {
1442        Ok(r) => Ok(r),
1443        Err(err) => Err(serde::de::Error::custom(format!(
1444            "{expecting}.\nThe underlying error message is:\n{err:#}"
1445        ))),
1446    }
1447}
1448
1449fn one_or_many_action<'de, D>(deserializer: D) -> Result<Vec<Action>, D::Error>
1450where
1451    D: Deserializer<'de>,
1452{
1453    one_or_many(
1454        deserializer,
1455        "\"action\" field expected either a single Action or an array of Actions",
1456    )
1457}
1458
1459fn regex_string_or_array<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error>
1460where
1461    D: Deserializer<'de>,
1462{
1463    string_or_array(
1464        deserializer,
1465        "regex string or array of regex strings for field regex",
1466    )
1467}
1468
1469fn string_or_array<'de, T, D>(deserializer: D, expecting: &'static str) -> Result<Vec<T>, D::Error>
1470where
1471    T: Deserialize<'de> + TryFrom<String>,
1472    <T as TryFrom<String>>::Error: std::fmt::Debug,
1473    D: Deserializer<'de>,
1474{
1475    // This is a Visitor that forwards string types to T's `TryFrom<String>` impl and
1476    // forwards map types to T's `Deserialize` impl. The `PhantomData` is to
1477    // keep the compiler from complaining about T being an unused generic type
1478    // parameter. We need T in order to know the Value type for the Visitor
1479    // impl.
1480    struct StringOrArray<T>(PhantomData<fn() -> T>, &'static str);
1481
1482    impl<'de, T> Visitor<'de> for StringOrArray<T>
1483    where
1484        T: Deserialize<'de> + TryFrom<String>,
1485        <T as TryFrom<String>>::Error: std::fmt::Debug,
1486    {
1487        type Value = Vec<T>;
1488
1489        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1490            formatter.write_str(self.1)
1491        }
1492
1493        fn visit_str<E>(self, value: &str) -> Result<Vec<T>, E>
1494        where
1495            E: serde::de::Error,
1496        {
1497            Ok(vec![
1498                T::try_from(value.to_string()).map_err(|e| E::custom(format!("{e:?}")))?
1499            ])
1500        }
1501
1502        fn visit_seq<S>(self, seq: S) -> Result<Vec<T>, S::Error>
1503        where
1504            S: SeqAccess<'de>,
1505        {
1506            Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
1507        }
1508    }
1509
1510    deserializer.deserialize_any(StringOrArray(PhantomData, expecting))
1511}
1512
1513#[cfg(feature = "lua")]
1514fn default_true() -> bool {
1515    true
1516}
1517
1518#[cfg(feature = "lua")]
1519pub fn register(lua: &mlua::Lua) -> anyhow::Result<()> {
1520    let shaping_mod = config::get_or_create_sub_module(lua, "shaping")?;
1521
1522    shaping_mod.set(
1523        "load",
1524        lua.create_async_function(
1525            move |lua, (paths, options): (Vec<String>, Option<mlua::Value>)| async move {
1526                let options = match options {
1527                    Some(v) => lua.from_value(v)?,
1528                    None => Default::default(),
1529                };
1530                let shaping = Shaping::merge_files(&paths, &options)
1531                    .await
1532                    .map_err(any_err)?;
1533                Ok(shaping)
1534            },
1535        )?,
1536    )?;
1537
1538    Ok(())
1539}
1540
1541#[cfg(test)]
1542mod test {
1543    use super::*;
1544    use kumo_log_types::RecordType;
1545    use rfc5321::Response;
1546    use std::io::Write;
1547    use tempfile::NamedTempFile;
1548    use uuid::Uuid;
1549
1550    async fn make_shaping_configs(inputs: &[&str]) -> Shaping {
1551        let mut files = vec![];
1552        let mut file_names = vec![];
1553
1554        for (i, content) in inputs.iter().enumerate() {
1555            let mut shaping_file = NamedTempFile::with_prefix(format!("file{i}")).unwrap();
1556            shaping_file.write_all(content.as_bytes()).unwrap();
1557            file_names.push(shaping_file.path().to_str().unwrap().to_string());
1558            files.push(shaping_file);
1559        }
1560
1561        Shaping::merge_files(&file_names, &ShapingMergeOptions::default())
1562            .await
1563            .unwrap()
1564    }
1565
1566    #[tokio::test]
1567    async fn test_merge_additional() {
1568        let shaping = make_shaping_configs(&[
1569            r#"
1570["example.com"]
1571mx_rollup = false
1572additional_connection_limits = {"first"=10}
1573        "#,
1574            r#"
1575["example.com"]
1576mx_rollup = false
1577additional_connection_limits = {"second"=32}
1578additional_message_rate_throttles = {"second"="100/hr"}
1579        "#,
1580        ])
1581        .await;
1582
1583        let resolved = shaping
1584            .get_egress_path_config("example.com", "invalid.source", "invalid.site")
1585            .await
1586            .finish()
1587            .unwrap();
1588
1589        k9::snapshot!(
1590            resolved.params.additional_connection_limits,
1591            r#"
1592{
1593    "first": 10,
1594    "second": 32,
1595}
1596"#
1597        );
1598        k9::snapshot!(
1599            resolved.params.additional_message_rate_throttles,
1600            r#"
1601{
1602    "second": 100/h,
1603}
1604"#
1605        );
1606    }
1607
1608    #[tokio::test]
1609    async fn test_provider_multi_hostname() {
1610        let shaping = make_shaping_configs(&[r#"
1611[provider."yahoo"]
1612match=[{HostName="mta5.am0.yahoodns.net"},{HostName="mta6.am0.yahoodns.net"},{HostName="mta7.am0.yahoodns.net"}]
1613enable_tls = "Required"
1614        "#])
1615        .await;
1616
1617        let resolved = shaping
1618            .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1619            .await
1620            .finish()
1621            .unwrap();
1622
1623        k9::assert_equal!(
1624            resolved.params.enable_tls,
1625            crate::egress_path::Tls::Required
1626        );
1627        k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1628    }
1629
1630    #[tokio::test]
1631    async fn test_provider_multi_suffix() {
1632        let shaping = make_shaping_configs(&[r#"
1633[provider."yahoo"]
1634match=[{MXSuffix="mta5.am0.yahoodns.net"},{MXSuffix="mta6.am0.yahoodns.net"},{MXSuffix="mta7.am0.yahoodns.net"}]
1635enable_tls = "Required"
1636        "#])
1637        .await;
1638
1639        let resolved = shaping
1640            .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1641            .await
1642            .finish()
1643            .unwrap();
1644
1645        k9::assert_equal!(
1646            resolved.params.enable_tls,
1647            crate::egress_path::Tls::Required
1648        );
1649        k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1650    }
1651
1652    #[tokio::test]
1653    async fn test_provider() {
1654        let shaping = make_shaping_configs(&[r#"
1655[provider."Office 365"]
1656match=[{MXSuffix=".olc.protection.outlook.com"},{DomainSuffix=".outlook.com"}]
1657enable_tls = "Required"
1658provider_connection_limit = 10
1659provider_max_message_rate = "120/s"
1660
1661[provider."Office 365".sources."new-source"]
1662provider_source_selection_rate = "500/d,max_burst=1"
1663        "#])
1664        .await;
1665
1666        let resolved = shaping
1667            .get_egress_path_config("outlook.com", "invalid.source", "invalid.site")
1668            .await
1669            .finish()
1670            .unwrap();
1671
1672        k9::assert_equal!(
1673            resolved.params.enable_tls,
1674            crate::egress_path::Tls::Required
1675        );
1676        k9::assert_equal!(resolved.params.provider_name.unwrap(), "Office 365");
1677
1678        k9::snapshot!(
1679            resolved.params.additional_connection_limits,
1680            r#"
1681{
1682    "shaping-provider-Office 365-invalid.source-limit": 10,
1683}
1684"#
1685        );
1686        k9::snapshot!(
1687            resolved.params.additional_message_rate_throttles,
1688            r#"
1689{
1690    "shaping-provider-Office 365-invalid.source-rate": 120/s,
1691}
1692"#
1693        );
1694        assert!(resolved.params.source_selection_rate.is_none());
1695        assert!(resolved.params.additional_source_selection_rates.is_empty());
1696
1697        let resolved = shaping
1698            .get_egress_path_config("outlook.com", "new-source", "invalid.site")
1699            .await
1700            .finish()
1701            .unwrap();
1702        assert!(resolved.params.source_selection_rate.is_none());
1703        k9::snapshot!(
1704            resolved.params.additional_source_selection_rates,
1705            r#"
1706{
1707    "shaping-provider-Office 365-new-source-selection-rate": 500/d,max_burst=1,
1708}
1709"#
1710        );
1711    }
1712
1713    #[tokio::test]
1714    async fn test_rule_matching() {
1715        let shaping = make_shaping_configs(&[r#"
1716[["default".automation]]
1717regex="default"
1718action = {SetConfig={name="connection_limit", value=1}}
1719duration = "1hr"
1720
1721["fake.site"]
1722_treat_domain_name_as_site_name = true
1723
1724[["fake.site".automation]]
1725regex="fake_rollup"
1726action = {SetConfig={name="connection_limit", value=2}}
1727duration = "1hr"
1728
1729["woot.provider"]
1730mx_rollup = false
1731
1732[["woot.provider".automation]]
1733regex="woot_domain"
1734action = {SetConfig={name="connection_limit", value=2}}
1735duration = "1hr"
1736
1737[provider."provider"]
1738match=[{DomainSuffix=".provider"}]
1739
1740[[provider."provider".automation]]
1741regex="provider"
1742action = {SetConfig={name="connection_limit", value=3}}
1743duration = "1hr"
1744match_internal = true
1745
1746"#])
1747        .await;
1748
1749        eprintln!("{:?}", shaping.inner.warnings);
1750
1751        fn make_record(content: &str, recipient: &str, site: &str) -> JsonLogRecord {
1752            JsonLogRecord {
1753                kind: RecordType::TransientFailure,
1754                id: String::new(),
1755                sender: String::new(),
1756                recipient: vec![recipient.to_string()],
1757                queue: String::new(),
1758                site: site.to_string(),
1759                size: 0,
1760                response: Response {
1761                    code: 400,
1762                    command: None,
1763                    enhanced_code: None,
1764                    content: content.to_string(),
1765                },
1766                peer_address: None,
1767                timestamp: Default::default(),
1768                created: Default::default(),
1769                num_attempts: 1,
1770                bounce_classification: Default::default(),
1771                egress_pool: None,
1772                egress_source: None,
1773                source_address: None,
1774                feedback_report: None,
1775                meta: Default::default(),
1776                headers: Default::default(),
1777                delivery_protocol: None,
1778                reception_protocol: None,
1779                nodeid: Uuid::default(),
1780                tls_cipher: None,
1781                tls_protocol_version: None,
1782                tls_peer_subject_name: None,
1783                provider_name: None,
1784                session_id: None,
1785            }
1786        }
1787
1788        let matches = shaping
1789            .match_rules(&make_record("default", "user@example.com", "dummy_site"))
1790            .await
1791            .unwrap();
1792        k9::assert_equal!(
1793            matches[0].regex[0].to_string(),
1794            "default",
1795            "matches against default automation rule"
1796        );
1797
1798        let matches = shaping
1799            .match_rules(&make_record(
1800                "KumoMTA internal: default",
1801                "user@example.com",
1802                "dummy_site",
1803            ))
1804            .await
1805            .unwrap();
1806        assert!(matches.is_empty(), "internal bounce should not match");
1807
1808        let matches = shaping
1809            .match_rules(&make_record(
1810                "woot_domain",
1811                "user@woot.provider",
1812                "dummy_site",
1813            ))
1814            .await
1815            .unwrap();
1816        k9::assert_equal!(
1817            matches[0].regex[0].to_string(),
1818            "woot_domain",
1819            "matches against domain rule with mx_rollup=false"
1820        );
1821
1822        let matches = shaping
1823            .match_rules(&make_record("fake_rollup", "user@fake.rollup", "fake.site"))
1824            .await
1825            .unwrap();
1826        k9::assert_equal!(
1827            matches[0].regex[0].to_string(),
1828            "fake_rollup",
1829            "matches against domain rule with mx_rollup=true"
1830        );
1831
1832        let matches = shaping
1833            .match_rules(&make_record("provider", "user@woot.provider", "dummy_site"))
1834            .await
1835            .unwrap();
1836        k9::assert_equal!(
1837            matches[0].regex[0].to_string(),
1838            "provider",
1839            "matches against provider rule"
1840        );
1841
1842        let matches = shaping
1843            .match_rules(&make_record(
1844                "KumoMTA internal: provider",
1845                "user@woot.provider",
1846                "dummy_site",
1847            ))
1848            .await
1849            .unwrap();
1850        k9::assert_equal!(
1851            matches[0].regex[0].to_string(),
1852            "provider",
1853            "internal response matches against provider rule"
1854        );
1855    }
1856
1857    #[tokio::test]
1858    async fn test_defaults() {
1859        let shaping = make_shaping_configs(&[
1860            r#"
1861["default"]
1862connection_limit = 10
1863max_connection_rate = "100/min"
1864max_deliveries_per_connection = 100
1865max_message_rate = "100/s"
1866idle_timeout = "60s"
1867data_timeout = "30s"
1868data_dot_timeout = "60s"
1869enable_tls = "Opportunistic"
1870consecutive_connection_failures_before_delay = 100
1871
1872[["default".automation]]
1873regex=[
1874        '/Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/',
1875        '/All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/',
1876        '/has been temporarily rate limited due to IP reputation/',
1877        '/Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/',
1878        '/Server busy\. Please try again later from/'
1879]
1880action = [
1881        {SetConfig={name="max_message_rate", value="1/minute"}},
1882        {SetConfig={name="connection_limit", value=1}}
1883]
1884duration = "90m"
1885
1886[["default".automation]]
1887regex="KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site"
1888action = {SetConfig={name="enable_tls", value="Disabled"}}
1889duration = "30 days"
1890
1891["gmail.com"]
1892max_deliveries_per_connection = 50
1893connection_limit = 5
1894enable_tls = "Required"
1895consecutive_connection_failures_before_delay = 5
1896
1897["yahoo.com"]
1898max_deliveries_per_connection = 20
1899
1900[["yahoo.com".automation]]
1901regex = "\\[TS04\\]"
1902action = "Suspend"
1903duration = "2 hours"
1904
1905["comcast.net"]
1906connection_limit = 25
1907max_deliveries_per_connection = 250
1908enable_tls = "Required"
1909idle_timeout = "30s"
1910consecutive_connection_failures_before_delay = 24
1911
1912["mail.com"]
1913max_deliveries_per_connection = 100
1914
1915["orange.fr"]
1916connection_limit = 3
1917
1918["smtp.mailgun.com"]
1919connection_limit = 7000
1920max_deliveries_per_connection = 3
1921
1922["example.com"]
1923mx_rollup = false
1924max_deliveries_per_connection = 100
1925connection_limit = 3
1926
1927["example.com".sources."my source name"]
1928connection_limit = 5
1929        "#,
1930        ])
1931        .await;
1932
1933        let default = shaping
1934            .get_egress_path_config("invalid.domain", "invalid.source", "invalid.site")
1935            .await
1936            .finish()
1937            .unwrap();
1938        k9::snapshot!(
1939            default,
1940            r#"
1941MergedEntry {
1942    params: EgressPathConfig {
1943        connection_limit: 10,
1944        additional_connection_limits: {},
1945        enable_tls: Opportunistic,
1946        enable_mta_sts: true,
1947        enable_dane: false,
1948        enable_pipelining: true,
1949        enable_rset: true,
1950        tls_prefer_openssl: false,
1951        tls_certificate: None,
1952        tls_private_key: None,
1953        openssl_cipher_list: None,
1954        openssl_cipher_suites: None,
1955        openssl_options: None,
1956        rustls_cipher_suites: [],
1957        client_timeouts: SmtpClientTimeouts {
1958            connect_timeout: 60s,
1959            banner_timeout: 60s,
1960            ehlo_timeout: 300s,
1961            mail_from_timeout: 300s,
1962            rcpt_to_timeout: 300s,
1963            data_timeout: 30s,
1964            data_dot_timeout: 60s,
1965            rset_timeout: 5s,
1966            idle_timeout: 60s,
1967            starttls_timeout: 5s,
1968            auth_timeout: 60s,
1969        },
1970        system_shutdown_timeout: None,
1971        max_ready: 1024,
1972        consecutive_connection_failures_before_delay: 100,
1973        smtp_port: 25,
1974        smtp_auth_plain_username: None,
1975        smtp_auth_plain_password: None,
1976        allow_smtp_auth_plain_without_tls: false,
1977        allow_smtp_auth_plain_without_valid_certificate: false,
1978        max_message_rate: Some(
1979            100/s,
1980        ),
1981        additional_message_rate_throttles: {},
1982        source_selection_rate: None,
1983        additional_source_selection_rates: {},
1984        max_connection_rate: Some(
1985            100/m,
1986        ),
1987        max_deliveries_per_connection: 100,
1988        max_recipients_per_batch: 100,
1989        prohibited_hosts: {
1990            "0.0.0.0",
1991            "127.0.0.0/8",
1992            "::/127",
1993        },
1994        skip_hosts: {},
1995        ip_lookup_strategy: Ipv4AndIpv6,
1996        ehlo_domain: None,
1997        aggressive_connection_opening: false,
1998        refresh_interval: 60s,
1999        refresh_strategy: Ttl,
2000        dispatcher_wakeup_strategy: Aggressive,
2001        maintainer_wakeup_strategy: Aggressive,
2002        provider_name: None,
2003        remember_broken_tls: None,
2004        opportunistic_tls_reconnect_on_failed_handshake: false,
2005        use_lmtp: false,
2006        reconnect_strategy: ConnectNextHost,
2007        readyq_pool_name: None,
2008        low_memory_reduction_policy: ShrinkDataAndMeta,
2009        no_memory_reduction_policy: ShrinkDataAndMeta,
2010        try_next_host_on_transport_error: false,
2011        ignore_8bit_checks: false,
2012        dispatcher_progress_watchdog_timeout: None,
2013    },
2014    sources: {},
2015    automation: [
2016        Rule {
2017            regex: [
2018                Regex(
2019                    /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2020                ),
2021                Regex(
2022                    /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2023                ),
2024                Regex(
2025                    /has been temporarily rate limited due to IP reputation/,
2026                ),
2027                Regex(
2028                    /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2029                ),
2030                Regex(
2031                    /Server busy\. Please try again later from/,
2032                ),
2033            ],
2034            action: [
2035                SetConfig(
2036                    EgressPathConfigValue {
2037                        name: "max_message_rate",
2038                        value: HashableTomlValue {
2039                            value: String(
2040                                "1/minute",
2041                            ),
2042                        },
2043                    },
2044                ),
2045                SetConfig(
2046                    EgressPathConfigValue {
2047                        name: "connection_limit",
2048                        value: HashableTomlValue {
2049                            value: Integer(
2050                                1,
2051                            ),
2052                        },
2053                    },
2054                ),
2055            ],
2056            trigger: Immediate,
2057            duration: 5400s,
2058            was_rollup: false,
2059            match_internal: false,
2060        },
2061        Rule {
2062            regex: [
2063                Regex(
2064                    KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2065                ),
2066            ],
2067            action: [
2068                SetConfig(
2069                    EgressPathConfigValue {
2070                        name: "enable_tls",
2071                        value: HashableTomlValue {
2072                            value: String(
2073                                "Disabled",
2074                            ),
2075                        },
2076                    },
2077                ),
2078            ],
2079            trigger: Immediate,
2080            duration: 2592000s,
2081            was_rollup: false,
2082            match_internal: false,
2083        },
2084    ],
2085}
2086"#
2087        );
2088
2089        let example_com = shaping
2090            .get_egress_path_config("example.com", "invalid.source", "invalid.site")
2091            .await
2092            .finish()
2093            .unwrap();
2094        k9::snapshot!(
2095            example_com,
2096            r#"
2097MergedEntry {
2098    params: EgressPathConfig {
2099        connection_limit: 3,
2100        additional_connection_limits: {},
2101        enable_tls: Opportunistic,
2102        enable_mta_sts: true,
2103        enable_dane: false,
2104        enable_pipelining: true,
2105        enable_rset: true,
2106        tls_prefer_openssl: false,
2107        tls_certificate: None,
2108        tls_private_key: None,
2109        openssl_cipher_list: None,
2110        openssl_cipher_suites: None,
2111        openssl_options: None,
2112        rustls_cipher_suites: [],
2113        client_timeouts: SmtpClientTimeouts {
2114            connect_timeout: 60s,
2115            banner_timeout: 60s,
2116            ehlo_timeout: 300s,
2117            mail_from_timeout: 300s,
2118            rcpt_to_timeout: 300s,
2119            data_timeout: 30s,
2120            data_dot_timeout: 60s,
2121            rset_timeout: 5s,
2122            idle_timeout: 60s,
2123            starttls_timeout: 5s,
2124            auth_timeout: 60s,
2125        },
2126        system_shutdown_timeout: None,
2127        max_ready: 1024,
2128        consecutive_connection_failures_before_delay: 100,
2129        smtp_port: 25,
2130        smtp_auth_plain_username: None,
2131        smtp_auth_plain_password: None,
2132        allow_smtp_auth_plain_without_tls: false,
2133        allow_smtp_auth_plain_without_valid_certificate: false,
2134        max_message_rate: Some(
2135            100/s,
2136        ),
2137        additional_message_rate_throttles: {},
2138        source_selection_rate: None,
2139        additional_source_selection_rates: {},
2140        max_connection_rate: Some(
2141            100/m,
2142        ),
2143        max_deliveries_per_connection: 100,
2144        max_recipients_per_batch: 100,
2145        prohibited_hosts: {
2146            "0.0.0.0",
2147            "127.0.0.0/8",
2148            "::/127",
2149        },
2150        skip_hosts: {},
2151        ip_lookup_strategy: Ipv4AndIpv6,
2152        ehlo_domain: None,
2153        aggressive_connection_opening: false,
2154        refresh_interval: 60s,
2155        refresh_strategy: Ttl,
2156        dispatcher_wakeup_strategy: Aggressive,
2157        maintainer_wakeup_strategy: Aggressive,
2158        provider_name: None,
2159        remember_broken_tls: None,
2160        opportunistic_tls_reconnect_on_failed_handshake: false,
2161        use_lmtp: false,
2162        reconnect_strategy: ConnectNextHost,
2163        readyq_pool_name: None,
2164        low_memory_reduction_policy: ShrinkDataAndMeta,
2165        no_memory_reduction_policy: ShrinkDataAndMeta,
2166        try_next_host_on_transport_error: false,
2167        ignore_8bit_checks: false,
2168        dispatcher_progress_watchdog_timeout: None,
2169    },
2170    sources: {
2171        "my source name": EgressPathConfig {
2172            connection_limit: 5,
2173            additional_connection_limits: {},
2174            enable_tls: Opportunistic,
2175            enable_mta_sts: true,
2176            enable_dane: false,
2177            enable_pipelining: true,
2178            enable_rset: true,
2179            tls_prefer_openssl: false,
2180            tls_certificate: None,
2181            tls_private_key: None,
2182            openssl_cipher_list: None,
2183            openssl_cipher_suites: None,
2184            openssl_options: None,
2185            rustls_cipher_suites: [],
2186            client_timeouts: SmtpClientTimeouts {
2187                connect_timeout: 60s,
2188                banner_timeout: 60s,
2189                ehlo_timeout: 300s,
2190                mail_from_timeout: 300s,
2191                rcpt_to_timeout: 300s,
2192                data_timeout: 300s,
2193                data_dot_timeout: 300s,
2194                rset_timeout: 5s,
2195                idle_timeout: 5s,
2196                starttls_timeout: 5s,
2197                auth_timeout: 60s,
2198            },
2199            system_shutdown_timeout: None,
2200            max_ready: 1024,
2201            consecutive_connection_failures_before_delay: 100,
2202            smtp_port: 25,
2203            smtp_auth_plain_username: None,
2204            smtp_auth_plain_password: None,
2205            allow_smtp_auth_plain_without_tls: false,
2206            allow_smtp_auth_plain_without_valid_certificate: false,
2207            max_message_rate: None,
2208            additional_message_rate_throttles: {},
2209            source_selection_rate: None,
2210            additional_source_selection_rates: {},
2211            max_connection_rate: None,
2212            max_deliveries_per_connection: 1024,
2213            max_recipients_per_batch: 100,
2214            prohibited_hosts: {
2215                "0.0.0.0",
2216                "127.0.0.0/8",
2217                "::/127",
2218            },
2219            skip_hosts: {},
2220            ip_lookup_strategy: Ipv4AndIpv6,
2221            ehlo_domain: None,
2222            aggressive_connection_opening: false,
2223            refresh_interval: 60s,
2224            refresh_strategy: Ttl,
2225            dispatcher_wakeup_strategy: Aggressive,
2226            maintainer_wakeup_strategy: Aggressive,
2227            provider_name: None,
2228            remember_broken_tls: None,
2229            opportunistic_tls_reconnect_on_failed_handshake: false,
2230            use_lmtp: false,
2231            reconnect_strategy: ConnectNextHost,
2232            readyq_pool_name: None,
2233            low_memory_reduction_policy: ShrinkDataAndMeta,
2234            no_memory_reduction_policy: ShrinkDataAndMeta,
2235            try_next_host_on_transport_error: false,
2236            ignore_8bit_checks: false,
2237            dispatcher_progress_watchdog_timeout: None,
2238        },
2239    },
2240    automation: [
2241        Rule {
2242            regex: [
2243                Regex(
2244                    /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2245                ),
2246                Regex(
2247                    /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2248                ),
2249                Regex(
2250                    /has been temporarily rate limited due to IP reputation/,
2251                ),
2252                Regex(
2253                    /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2254                ),
2255                Regex(
2256                    /Server busy\. Please try again later from/,
2257                ),
2258            ],
2259            action: [
2260                SetConfig(
2261                    EgressPathConfigValue {
2262                        name: "max_message_rate",
2263                        value: HashableTomlValue {
2264                            value: String(
2265                                "1/minute",
2266                            ),
2267                        },
2268                    },
2269                ),
2270                SetConfig(
2271                    EgressPathConfigValue {
2272                        name: "connection_limit",
2273                        value: HashableTomlValue {
2274                            value: Integer(
2275                                1,
2276                            ),
2277                        },
2278                    },
2279                ),
2280            ],
2281            trigger: Immediate,
2282            duration: 5400s,
2283            was_rollup: false,
2284            match_internal: false,
2285        },
2286        Rule {
2287            regex: [
2288                Regex(
2289                    KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2290                ),
2291            ],
2292            action: [
2293                SetConfig(
2294                    EgressPathConfigValue {
2295                        name: "enable_tls",
2296                        value: HashableTomlValue {
2297                            value: String(
2298                                "Disabled",
2299                            ),
2300                        },
2301                    },
2302                ),
2303            ],
2304            trigger: Immediate,
2305            duration: 2592000s,
2306            was_rollup: false,
2307            match_internal: false,
2308        },
2309    ],
2310}
2311"#
2312        );
2313
2314        // The site name here will need to be updated if yahoo changes
2315        // their MX records
2316        let yahoo_com = shaping
2317            .get_egress_path_config(
2318                "yahoo.com",
2319                "invalid.source",
2320                "(mta5|mta6|mta7).am0.yahoodns.net",
2321            )
2322            .await
2323            .finish()
2324            .unwrap();
2325        k9::snapshot!(
2326            yahoo_com,
2327            r#"
2328MergedEntry {
2329    params: EgressPathConfig {
2330        connection_limit: 10,
2331        additional_connection_limits: {},
2332        enable_tls: Opportunistic,
2333        enable_mta_sts: true,
2334        enable_dane: false,
2335        enable_pipelining: true,
2336        enable_rset: true,
2337        tls_prefer_openssl: false,
2338        tls_certificate: None,
2339        tls_private_key: None,
2340        openssl_cipher_list: None,
2341        openssl_cipher_suites: None,
2342        openssl_options: None,
2343        rustls_cipher_suites: [],
2344        client_timeouts: SmtpClientTimeouts {
2345            connect_timeout: 60s,
2346            banner_timeout: 60s,
2347            ehlo_timeout: 300s,
2348            mail_from_timeout: 300s,
2349            rcpt_to_timeout: 300s,
2350            data_timeout: 30s,
2351            data_dot_timeout: 60s,
2352            rset_timeout: 5s,
2353            idle_timeout: 60s,
2354            starttls_timeout: 5s,
2355            auth_timeout: 60s,
2356        },
2357        system_shutdown_timeout: None,
2358        max_ready: 1024,
2359        consecutive_connection_failures_before_delay: 100,
2360        smtp_port: 25,
2361        smtp_auth_plain_username: None,
2362        smtp_auth_plain_password: None,
2363        allow_smtp_auth_plain_without_tls: false,
2364        allow_smtp_auth_plain_without_valid_certificate: false,
2365        max_message_rate: Some(
2366            100/s,
2367        ),
2368        additional_message_rate_throttles: {},
2369        source_selection_rate: None,
2370        additional_source_selection_rates: {},
2371        max_connection_rate: Some(
2372            100/m,
2373        ),
2374        max_deliveries_per_connection: 20,
2375        max_recipients_per_batch: 100,
2376        prohibited_hosts: {
2377            "0.0.0.0",
2378            "127.0.0.0/8",
2379            "::/127",
2380        },
2381        skip_hosts: {},
2382        ip_lookup_strategy: Ipv4AndIpv6,
2383        ehlo_domain: None,
2384        aggressive_connection_opening: false,
2385        refresh_interval: 60s,
2386        refresh_strategy: Ttl,
2387        dispatcher_wakeup_strategy: Aggressive,
2388        maintainer_wakeup_strategy: Aggressive,
2389        provider_name: None,
2390        remember_broken_tls: None,
2391        opportunistic_tls_reconnect_on_failed_handshake: false,
2392        use_lmtp: false,
2393        reconnect_strategy: ConnectNextHost,
2394        readyq_pool_name: None,
2395        low_memory_reduction_policy: ShrinkDataAndMeta,
2396        no_memory_reduction_policy: ShrinkDataAndMeta,
2397        try_next_host_on_transport_error: false,
2398        ignore_8bit_checks: false,
2399        dispatcher_progress_watchdog_timeout: None,
2400    },
2401    sources: {},
2402    automation: [
2403        Rule {
2404            regex: [
2405                Regex(
2406                    /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2407                ),
2408                Regex(
2409                    /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2410                ),
2411                Regex(
2412                    /has been temporarily rate limited due to IP reputation/,
2413                ),
2414                Regex(
2415                    /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2416                ),
2417                Regex(
2418                    /Server busy\. Please try again later from/,
2419                ),
2420            ],
2421            action: [
2422                SetConfig(
2423                    EgressPathConfigValue {
2424                        name: "max_message_rate",
2425                        value: HashableTomlValue {
2426                            value: String(
2427                                "1/minute",
2428                            ),
2429                        },
2430                    },
2431                ),
2432                SetConfig(
2433                    EgressPathConfigValue {
2434                        name: "connection_limit",
2435                        value: HashableTomlValue {
2436                            value: Integer(
2437                                1,
2438                            ),
2439                        },
2440                    },
2441                ),
2442            ],
2443            trigger: Immediate,
2444            duration: 5400s,
2445            was_rollup: false,
2446            match_internal: false,
2447        },
2448        Rule {
2449            regex: [
2450                Regex(
2451                    KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2452                ),
2453            ],
2454            action: [
2455                SetConfig(
2456                    EgressPathConfigValue {
2457                        name: "enable_tls",
2458                        value: HashableTomlValue {
2459                            value: String(
2460                                "Disabled",
2461                            ),
2462                        },
2463                    },
2464                ),
2465            ],
2466            trigger: Immediate,
2467            duration: 2592000s,
2468            was_rollup: false,
2469            match_internal: false,
2470        },
2471        Rule {
2472            regex: [
2473                Regex(
2474                    \[TS04\],
2475                ),
2476            ],
2477            action: [
2478                Suspend,
2479            ],
2480            trigger: Immediate,
2481            duration: 7200s,
2482            was_rollup: false,
2483            match_internal: false,
2484        },
2485    ],
2486}
2487"#
2488        );
2489    }
2490
2491    #[tokio::test]
2492    async fn test_load_default_shaping_toml() {
2493        Shaping::merge_files(
2494            &["../../assets/policy-extras/shaping.toml".into()],
2495            &ShapingMergeOptions::default(),
2496        )
2497        .await
2498        .unwrap();
2499    }
2500}