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                            tracing::debug!(
751                                target: "shaping_load",
752                                %domain,
753                                "dropping domain from shaping: MX resolve failed: {err:#}"
754                            );
755                            collector.push(
756                                options.dns_fail,
757                                format!(
758                                    "error resolving MX for {domain}: {err:#}. \
759                                 Ignoring the shaping config for that domain."
760                                ),
761                            );
762                            continue;
763                        }
764                        None => {
765                            tracing::debug!(
766                                target: "shaping_load",
767                                %domain,
768                                "dropping domain from shaping: MX was not resolved"
769                            );
770                            collector.push(
771                                options.dns_fail,
772                                format!(
773                                "We didn't try to resolve the MX for {domain} for some reason!?. \
774                                 Ignoring the shaping config for that domain."
775                            ),
776                            );
777                            continue;
778                        }
779                    };
780
781                    if mx.site_name.is_empty() {
782                        collector.push(
783                            options.null_mx,
784                            format!(
785                            "domain {domain} has a NULL MX and cannot be used with mx_rollup=true. \
786                             Ignoring the shaping config for that domain."),
787                        );
788                        continue;
789                    }
790
791                    tracing::trace!(
792                        target: "shaping_load",
793                        %domain,
794                        site_name = %mx.site_name,
795                        "domain resolved to site_name"
796                    );
797
798                    site_aliases
799                        .entry(mx.site_name.to_string())
800                        .or_default()
801                        .push(domain.to_string());
802
803                    match by_site.get_mut(&mx.site_name) {
804                        Some(existing) => {
805                            existing.merge_from(partial);
806                        }
807                        None => {
808                            by_site.insert(mx.site_name.clone(), partial);
809                        }
810                    }
811                } else {
812                    match by_domain.get_mut(&domain) {
813                        Some(existing) => {
814                            existing.merge_from(partial);
815                        }
816                        None => {
817                            by_domain.insert(domain, partial);
818                        }
819                    }
820                }
821            }
822
823            for (provider, mut prov) in item.provider {
824                prov.provider_name = provider.to_string();
825                match by_provider.get_mut(&provider) {
826                    Some(existing) => {
827                        existing.merge_from(prov);
828                    }
829                    None => {
830                        by_provider.insert(provider.to_string(), prov);
831                    }
832                }
833            }
834        }
835
836        for (site, partial) in &by_site {
837            partial
838                .clone()
839                .finish()
840                .with_context(|| format!("site: {site}"))?;
841        }
842
843        for (domain, partial) in &by_domain {
844            partial
845                .clone()
846                .finish()
847                .with_context(|| format!("domain: {domain}"))?;
848        }
849
850        for (provider, prov) in &by_provider {
851            prov.finish_params()
852                .with_context(|| format!("provider: {provider}"))?;
853        }
854
855        if options.aliased_site != CheckLevel::Ignore {
856            for (site, aliases) in site_aliases {
857                if aliases.len() > 1 {
858                    collector.push(
859                        options.aliased_site,
860                        format!(
861                            "multiple domain blocks alias to the same site: {site}: {}",
862                            aliases.join(", ")
863                        ),
864                    );
865                }
866            }
867        }
868
869        if options.provider_overlap != CheckLevel::Ignore {
870            for domain in mx.keys() {
871                let mut matching_providers = vec![];
872                for (prov_name, prov) in &by_provider {
873                    if prov.domain_matches(domain).await {
874                        matching_providers.push(prov_name.to_string());
875                    }
876                }
877                if !matching_providers.is_empty() {
878                    collector.push(
879                        options.provider_overlap,
880                        format!(
881                            "domain {domain} is also matched by provider(s): {}",
882                            matching_providers.join(", ")
883                        ),
884                    );
885                }
886            }
887        }
888
889        let mut ctx = Sha256::new();
890        ctx.update("by_site");
891        for (site, entry) in &by_site {
892            ctx.update(site);
893            entry.hash_into(&mut ctx);
894        }
895        ctx.update("by_domain");
896        for (domain, entry) in &by_domain {
897            ctx.update(domain);
898            entry.hash_into(&mut ctx);
899        }
900        ctx.update("by_provider");
901        for (provider, prov) in &by_provider {
902            ctx.update(provider);
903            prov.hash_into(&mut ctx);
904        }
905        ctx.update("warnings");
906        for warn in &collector.warnings {
907            ctx.update(warn);
908        }
909        ctx.update("errors");
910        for err in &collector.errors {
911            ctx.update(err);
912        }
913        let hash = ctx.finalize();
914        let hash = data_encoding::HEXLOWER.encode(&hash);
915
916        tracing::debug!(
917            target: "shaping_load",
918            %hash,
919            sites = by_site.len(),
920            domains = by_domain.len(),
921            providers = by_provider.len(),
922            warnings = collector.warnings.len(),
923            errors = collector.errors.len(),
924            "merged shaping config"
925        );
926
927        Ok(Self {
928            inner: Arc::new(ShapingInner {
929                by_site,
930                by_domain,
931                by_provider,
932                warnings: collector.warnings,
933                errors: collector.errors,
934                hash,
935            }),
936        })
937    }
938
939    async fn get_egress_path_config(
940        &self,
941        domain: &str,
942        egress_source: &str,
943        site_name: &str,
944    ) -> PartialEntry {
945        self.inner
946            .get_egress_path_config(domain, egress_source, site_name)
947            .await
948    }
949
950    pub async fn get_egress_path_config_value(
951        &self,
952        domain: &str,
953        egress_source: &str,
954        site_name: &str,
955    ) -> anyhow::Result<serde_json::Value> {
956        let partial = self
957            .get_egress_path_config(domain, egress_source, site_name)
958            .await;
959        Ok(serde_json::to_value(&partial)?)
960    }
961
962    pub fn get_errors(&self) -> &[String] {
963        &self.inner.errors
964    }
965
966    pub fn get_warnings(&self) -> &[String] {
967        &self.inner.warnings
968    }
969
970    pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result<Vec<Rule>> {
971        self.inner.match_rules(record).await
972    }
973
974    pub fn get_referenced_sources(&self) -> BTreeMap<String, Vec<String>> {
975        let mut result = BTreeMap::new();
976
977        for (site_name, site) in &self.inner.by_site {
978            for source_name in site.sources.keys() {
979                result
980                    .entry(source_name.to_string())
981                    .or_insert(vec![])
982                    .push(format!("site:{site_name}"));
983            }
984        }
985        for (domain_name, domain) in &self.inner.by_domain {
986            for source_name in domain.sources.keys() {
987                result
988                    .entry(source_name.to_string())
989                    .or_insert(vec![])
990                    .push(format!("domain:{domain_name}"));
991            }
992        }
993
994        result
995    }
996
997    pub fn hash(&self) -> String {
998        self.inner.hash.clone()
999    }
1000}
1001
1002#[cfg(feature = "lua")]
1003impl LuaUserData for Shaping {
1004    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
1005        mod_memoize::Memoized::impl_memoize(methods);
1006        methods.add_async_method(
1007            "get_egress_path_config",
1008            |lua, this, (domain, egress_source, site_name): (String, String, String)| async move {
1009                let params = this
1010                    .get_egress_path_config(&domain, &egress_source, &site_name)
1011                    .await;
1012                lua.to_value_with(&params.params, serialize_options())
1013            },
1014        );
1015
1016        methods.add_method("get_errors", move |_lua, this, ()| {
1017            let errors: Vec<String> = this.get_errors().iter().map(|s| s.to_string()).collect();
1018            Ok(errors)
1019        });
1020
1021        methods.add_method("get_warnings", move |_lua, this, ()| {
1022            let warnings: Vec<String> = this.get_warnings().iter().map(|s| s.to_string()).collect();
1023            Ok(warnings)
1024        });
1025
1026        methods.add_method("get_referenced_sources", move |_lua, this, ()| {
1027            Ok(this.get_referenced_sources())
1028        });
1029
1030        methods.add_async_method("match_rules", |lua, this, record: mlua::Value| async move {
1031            let record: JsonLogRecord = lua.from_value(record)?;
1032            let rules = this.match_rules(&record).await.map_err(any_err)?;
1033            let mut result = vec![];
1034            for rule in rules {
1035                result.push(lua.to_value(&rule)?);
1036            }
1037            Ok(result)
1038        });
1039
1040        methods.add_method("hash", move |_, this, ()| Ok(this.hash()));
1041    }
1042}
1043
1044#[derive(Default, Debug)]
1045pub struct MergedEntry {
1046    pub params: EgressPathConfig,
1047    pub sources: OrderMap<String, EgressPathConfig>,
1048    pub automation: Vec<Rule>,
1049}
1050
1051#[cfg(feature = "lua")]
1052#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1053struct ShapingFile {
1054    pub default: Option<PartialEntry>,
1055    #[serde(flatten, default)]
1056    pub domains: OrderMap<String, PartialEntry>,
1057    #[serde(default)]
1058    pub provider: OrderMap<String, ProviderEntry>,
1059}
1060
1061#[cfg(feature = "lua")]
1062#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1063struct PartialEntry {
1064    #[serde(skip)]
1065    pub domain_name: Option<String>,
1066
1067    #[serde(flatten)]
1068    pub params: toml::Table,
1069
1070    #[serde(default = "default_true")]
1071    pub mx_rollup: bool,
1072
1073    // This is present to facilitate unit testing without requiring
1074    // DNS to resolve the site_name. When set to true, the domain_name
1075    // is considered to be the site_name for this entry.
1076    #[cfg(test)]
1077    #[serde(default)]
1078    pub _treat_domain_name_as_site_name: bool,
1079
1080    #[serde(default)]
1081    pub replace_base: bool,
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")]
1091#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1092pub struct ProviderEntry {
1093    #[serde(skip, default)]
1094    pub provider_name: String,
1095
1096    #[serde(default)]
1097    pub provider_connection_limit: Option<LimitSpec>,
1098
1099    #[serde(default)]
1100    pub provider_max_message_rate: Option<ThrottleSpec>,
1101
1102    #[serde(default, rename = "match")]
1103    pub matches: Vec<ProviderMatch>,
1104
1105    #[serde(default)]
1106    pub replace_base: bool,
1107
1108    #[serde(flatten)]
1109    pub params: toml::Table,
1110
1111    #[serde(default)]
1112    pub automation: Vec<Rule>,
1113
1114    #[serde(default)]
1115    pub sources: OrderMap<String, toml::Table>,
1116}
1117
1118#[cfg(feature = "lua")]
1119fn suffix_matches(candidate: &str, suffix: &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    // We DON'T handle case mismatches in this code.
1124    // We assume that the dns-resolver crate normalized the
1125    // names to lowercase and that the input suffix is lowercase as well.
1126    candidate.ends_with(suffix)
1127}
1128
1129#[cfg(feature = "lua")]
1130#[cfg(test)]
1131#[test]
1132fn test_suffix_matches() {
1133    assert!(suffix_matches("a", "a"));
1134    assert!(suffix_matches("foo.com", "foo.com"));
1135    assert!(!suffix_matches("foo.com", ".foo.com"));
1136    assert!(!suffix_matches("foo.com", "longer.com"));
1137    assert!(!suffix_matches("réputation.net", ".mx.microsoft"));
1138
1139    // We DON'T handle case mismatches in this code.
1140    // We assume that the dns-resolver crate normalized the
1141    // names to lowercase and that the input suffix is lowercase as well.
1142    assert!(!suffix_matches("foo.Com", ".com"));
1143    assert!(!suffix_matches("foo.Cam", ".com"));
1144}
1145
1146#[cfg(feature = "lua")]
1147fn host_matches(candidate: &str, name: &str) -> bool {
1148    // Remove trailing dot from candidate, as our resolver tends
1149    // to leave the canonical dot on the input host name
1150    let candidate = candidate.strip_suffix(".").unwrap_or(candidate);
1151    candidate == name
1152}
1153
1154#[cfg(feature = "lua")]
1155#[cfg(test)]
1156#[test]
1157fn test_host_matches() {
1158    assert!(host_matches("foo.com", "foo.com"));
1159    assert!(host_matches("foo.com.", "foo.com"));
1160    assert!(!host_matches("foo.com", "notfoo.com"));
1161}
1162
1163#[cfg(feature = "lua")]
1164impl ProviderEntry {
1165    async fn domain_matches(&self, domain: &str) -> bool {
1166        // We'd like to avoid doing DNS if we can do a simple suffix match,
1167        // so we bias to looking at those first
1168        let mut need_mx = false;
1169
1170        tracing::trace!(
1171            "ProviderEntry::domain_matches({domain}) vs {:?}",
1172            self.matches
1173        );
1174
1175        for rule in &self.matches {
1176            match rule {
1177                ProviderMatch::DomainSuffix(suffix) => {
1178                    if suffix_matches(domain, suffix) {
1179                        tracing::trace!("{domain} suffix matches {suffix}");
1180                        return true;
1181                    }
1182                }
1183                ProviderMatch::HostName(_) | ProviderMatch::MXSuffix(_) => {
1184                    need_mx = true;
1185                }
1186            }
1187        }
1188
1189        if !need_mx {
1190            return false;
1191        }
1192
1193        // Now we can consider DNS
1194        match MailExchanger::resolve(domain).await {
1195            Err(err) => {
1196                // Didn't resolve; could be legit, for example, could
1197                // be an internal or fake name that is handled via
1198                // smart host or custom routing, so we log what
1199                // happened as a trace rather than polluting the
1200                // logs about it
1201                tracing::trace!(
1202                    "Error resolving MX for {domain}: {err:#}. \
1203                    Provider {} match rules will be ignored",
1204                    self.provider_name
1205                );
1206                false
1207            }
1208            Ok(mx) => {
1209                tracing::trace!("Consider MXSuffix rules");
1210                for host in &mx.hosts {
1211                    let mut matched = false;
1212
1213                    for rule in &self.matches {
1214                        match rule {
1215                            ProviderMatch::MXSuffix(suffix) => {
1216                                // For a given MX suffix rule, all hosts must match
1217                                // it for it to be valid. This is so that we don't
1218                                // falsely lump a vanity domain that blends providers
1219                                // together.
1220                                tracing::trace!("suffix={suffix} vs host {host}");
1221                                if suffix_matches(host, suffix) {
1222                                    matched = true;
1223                                    break;
1224                                }
1225                            }
1226                            ProviderMatch::HostName(name) => {
1227                                if host_matches(host, name) {
1228                                    matched = true;
1229                                    break;
1230                                }
1231                            }
1232                            ProviderMatch::DomainSuffix(_) => {}
1233                        }
1234                    }
1235
1236                    if !matched {
1237                        tracing::trace!("host didn't match any of these rules");
1238                        return false;
1239                    }
1240                }
1241
1242                true
1243            }
1244        }
1245    }
1246
1247    fn merge_from(&mut self, mut other: Self) {
1248        if other.replace_base {
1249            self.provider_connection_limit = other.provider_connection_limit;
1250            self.matches = other.matches;
1251            self.params = other.params;
1252            self.sources = other.sources;
1253            self.automation = other.automation;
1254        } else {
1255            if other.provider_connection_limit.is_some() {
1256                self.provider_connection_limit = other.provider_connection_limit;
1257            }
1258
1259            toml_table_merge_from(&mut self.params, &other.params);
1260
1261            for (source, tbl) in other.sources {
1262                match self.sources.get_mut(&source) {
1263                    Some(existing) => {
1264                        toml_table_merge_from(existing, &tbl);
1265                    }
1266                    None => {
1267                        self.sources.insert(source, tbl);
1268                    }
1269                }
1270            }
1271
1272            self.matches.append(&mut other.matches);
1273            self.automation.append(&mut other.automation);
1274        }
1275    }
1276
1277    fn apply_provider_params_to(&self, source: &str, target: &mut toml::Table) {
1278        let mut implied = toml::Table::new();
1279        implied.insert(
1280            "provider_name".to_string(),
1281            toml::Value::String(self.provider_name.to_string()),
1282        );
1283
1284        if let Some(limit) = &self.provider_connection_limit {
1285            let mut limits = toml::Table::new();
1286            limits.insert(
1287                format!("shaping-provider-{}-{source}-limit", self.provider_name),
1288                toml::Value::String(limit.to_string()),
1289            );
1290            implied.insert(
1291                "additional_connection_limits".to_string(),
1292                toml::Value::Table(limits),
1293            );
1294        }
1295        if let Some(rate) = &self.provider_max_message_rate {
1296            let rate = rate.as_string();
1297            let mut limits = toml::Table::new();
1298            limits.insert(
1299                format!("shaping-provider-{}-{source}-rate", self.provider_name),
1300                rate.into(),
1301            );
1302            implied.insert(
1303                "additional_message_rate_throttles".to_string(),
1304                toml::Value::Table(limits),
1305            );
1306        }
1307
1308        if let Some(rate) = target.remove("provider_source_selection_rate") {
1309            let mut limits = toml::Table::new();
1310            limits.insert(
1311                format!(
1312                    "shaping-provider-{}-{source}-selection-rate",
1313                    self.provider_name
1314                ),
1315                rate,
1316            );
1317            implied.insert(
1318                "additional_source_selection_rates".to_string(),
1319                toml::Value::Table(limits),
1320            );
1321        }
1322
1323        toml_table_merge_from(target, &implied);
1324    }
1325
1326    fn finish_params(&self) -> anyhow::Result<MergedEntry> {
1327        let provider_name = &self.provider_name;
1328
1329        let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1330            format!(
1331                "interpreting provider '{provider_name}' params {:#?} as EgressPathConfig",
1332                self.params
1333            )
1334        })?;
1335        let mut sources = OrderMap::new();
1336
1337        for (source, params) in &self.sources {
1338            let mut params = params.clone();
1339            // I don't really like this remove call. The issue is that we don't
1340            // have an alternative way to filter this out of the partial source
1341            // definition, and this provider_ option is not valid in the
1342            // EgressPathConfig struct itself; it is a shaping source-specific
1343            // addition to help with setting up shared throttles across providers
1344            params.remove("provider_source_selection_rate");
1345            sources.insert(
1346                source.clone(),
1347                EgressPathConfig::deserialize(params.clone()).with_context(|| {
1348                    format!("interpreting provider '{provider_name}' source '{source}' {params:#} as EgressPathConfig")
1349                })?,
1350            );
1351        }
1352
1353        Ok(MergedEntry {
1354            params,
1355            sources,
1356            automation: self.automation.clone(),
1357        })
1358    }
1359
1360    fn hash_into(&self, ctx: &mut Sha256) {
1361        ctx.update(&self.provider_name);
1362        ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1363    }
1364}
1365
1366#[cfg(feature = "lua")]
1367#[derive(Deserialize, Serialize, Debug, Clone)]
1368pub enum ProviderMatch {
1369    MXSuffix(String),
1370    DomainSuffix(String),
1371    HostName(String),
1372}
1373
1374#[cfg(feature = "lua")]
1375fn toml_table_merge_from(tbl: &mut toml::Table, source: &toml::Table) {
1376    // Limit merging to just the throttle related fields, as their purpose
1377    // is for creating broader scoped limits that cut across normal boundaries
1378    fn is_mergeable(name: &str) -> bool {
1379        match name {
1380            "additional_connection_limits"
1381            | "additional_message_rate_throttles"
1382            | "additional_source_selection_rates" => true,
1383            _ => false,
1384        }
1385    }
1386
1387    for (k, v) in source {
1388        match (tbl.get_mut(k), v.as_table()) {
1389            // Merge Table values together, rather than simply replacing them.
1390            (Some(toml::Value::Table(existing)), Some(v)) if is_mergeable(k) => {
1391                for (inner_k, inner_v) in v {
1392                    existing.insert(inner_k.clone(), inner_v.clone());
1393                }
1394            }
1395            _ => {
1396                tbl.insert(k.clone(), v.clone());
1397            }
1398        }
1399    }
1400}
1401
1402#[cfg(feature = "lua")]
1403impl PartialEntry {
1404    fn merge_from(&mut self, mut other: Self) {
1405        if other.replace_base {
1406            self.params = other.params;
1407            self.automation = other.automation;
1408            self.sources = other.sources;
1409        } else {
1410            toml_table_merge_from(&mut self.params, &other.params);
1411
1412            for (source, tbl) in other.sources {
1413                match self.sources.get_mut(&source) {
1414                    Some(existing) => {
1415                        toml_table_merge_from(existing, &tbl);
1416                    }
1417                    None => {
1418                        self.sources.insert(source, tbl);
1419                    }
1420                }
1421            }
1422
1423            self.automation.append(&mut other.automation);
1424        }
1425    }
1426
1427    fn finish(self) -> anyhow::Result<MergedEntry> {
1428        let domain = self.domain_name.unwrap_or_default();
1429
1430        let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1431            format!(
1432                "interpreting domain '{domain}' params {:#?} as EgressPathConfig",
1433                self.params
1434            )
1435        })?;
1436        let mut sources = OrderMap::new();
1437
1438        for (source, params) in self.sources {
1439            sources.insert(
1440                source.clone(),
1441                EgressPathConfig::deserialize(params.clone()).with_context(|| {
1442                    format!("interpreting domain '{domain}' source '{source}' {params:#} as EgressPathConfig")
1443                })?,
1444            );
1445        }
1446
1447        Ok(MergedEntry {
1448            params,
1449            sources,
1450            automation: self.automation,
1451        })
1452    }
1453
1454    fn hash_into(&self, ctx: &mut Sha256) {
1455        if let Some(name) = self.domain_name.as_ref() {
1456            ctx.update(name)
1457        }
1458        ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1459    }
1460}
1461
1462fn one_or_many<'de, T, D>(deserializer: D, expecting: &str) -> Result<Vec<T>, D::Error>
1463where
1464    T: Deserialize<'de>,
1465    D: Deserializer<'de>,
1466{
1467    let result: Result<Vec<T>, _> =
1468        OneOrMany::<serde_with::Same, PreferOne>::deserialize_as(deserializer);
1469    match result {
1470        Ok(r) => Ok(r),
1471        Err(err) => Err(serde::de::Error::custom(format!(
1472            "{expecting}.\nThe underlying error message is:\n{err:#}"
1473        ))),
1474    }
1475}
1476
1477fn one_or_many_action<'de, D>(deserializer: D) -> Result<Vec<Action>, D::Error>
1478where
1479    D: Deserializer<'de>,
1480{
1481    one_or_many(
1482        deserializer,
1483        "\"action\" field expected either a single Action or an array of Actions",
1484    )
1485}
1486
1487fn regex_string_or_array<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error>
1488where
1489    D: Deserializer<'de>,
1490{
1491    string_or_array(
1492        deserializer,
1493        "regex string or array of regex strings for field regex",
1494    )
1495}
1496
1497fn string_or_array<'de, T, D>(deserializer: D, expecting: &'static str) -> Result<Vec<T>, D::Error>
1498where
1499    T: Deserialize<'de> + TryFrom<String>,
1500    <T as TryFrom<String>>::Error: std::fmt::Debug,
1501    D: Deserializer<'de>,
1502{
1503    // This is a Visitor that forwards string types to T's `TryFrom<String>` impl and
1504    // forwards map types to T's `Deserialize` impl. The `PhantomData` is to
1505    // keep the compiler from complaining about T being an unused generic type
1506    // parameter. We need T in order to know the Value type for the Visitor
1507    // impl.
1508    struct StringOrArray<T>(PhantomData<fn() -> T>, &'static str);
1509
1510    impl<'de, T> Visitor<'de> for StringOrArray<T>
1511    where
1512        T: Deserialize<'de> + TryFrom<String>,
1513        <T as TryFrom<String>>::Error: std::fmt::Debug,
1514    {
1515        type Value = Vec<T>;
1516
1517        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1518            formatter.write_str(self.1)
1519        }
1520
1521        fn visit_str<E>(self, value: &str) -> Result<Vec<T>, E>
1522        where
1523            E: serde::de::Error,
1524        {
1525            Ok(vec![
1526                T::try_from(value.to_string()).map_err(|e| E::custom(format!("{e:?}")))?
1527            ])
1528        }
1529
1530        fn visit_seq<S>(self, seq: S) -> Result<Vec<T>, S::Error>
1531        where
1532            S: SeqAccess<'de>,
1533        {
1534            Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
1535        }
1536    }
1537
1538    deserializer.deserialize_any(StringOrArray(PhantomData, expecting))
1539}
1540
1541#[cfg(feature = "lua")]
1542fn default_true() -> bool {
1543    true
1544}
1545
1546#[cfg(feature = "lua")]
1547pub fn register(lua: &mlua::Lua) -> anyhow::Result<()> {
1548    let shaping_mod = config::get_or_create_sub_module(lua, "shaping")?;
1549
1550    shaping_mod.set(
1551        "load",
1552        lua.create_async_function(
1553            move |lua, (paths, options): (Vec<String>, Option<mlua::Value>)| async move {
1554                let options = match options {
1555                    Some(v) => lua.from_value(v)?,
1556                    None => Default::default(),
1557                };
1558                let shaping = Shaping::merge_files(&paths, &options)
1559                    .await
1560                    .map_err(any_err)?;
1561                Ok(shaping)
1562            },
1563        )?,
1564    )?;
1565
1566    Ok(())
1567}
1568
1569#[cfg(test)]
1570mod test {
1571    use super::*;
1572    use kumo_log_types::RecordType;
1573    use rfc5321::Response;
1574    use std::io::Write;
1575    use tempfile::NamedTempFile;
1576    use uuid::Uuid;
1577
1578    async fn make_shaping_configs(inputs: &[&str]) -> Shaping {
1579        let mut files = vec![];
1580        let mut file_names = vec![];
1581
1582        for (i, content) in inputs.iter().enumerate() {
1583            let mut shaping_file = NamedTempFile::with_prefix(format!("file{i}")).unwrap();
1584            shaping_file.write_all(content.as_bytes()).unwrap();
1585            file_names.push(shaping_file.path().to_str().unwrap().to_string());
1586            files.push(shaping_file);
1587        }
1588
1589        Shaping::merge_files(&file_names, &ShapingMergeOptions::default())
1590            .await
1591            .unwrap()
1592    }
1593
1594    #[tokio::test]
1595    async fn test_merge_additional() {
1596        let shaping = make_shaping_configs(&[
1597            r#"
1598["example.com"]
1599mx_rollup = false
1600additional_connection_limits = {"first"=10}
1601        "#,
1602            r#"
1603["example.com"]
1604mx_rollup = false
1605additional_connection_limits = {"second"=32}
1606additional_message_rate_throttles = {"second"="100/hr"}
1607        "#,
1608        ])
1609        .await;
1610
1611        let resolved = shaping
1612            .get_egress_path_config("example.com", "invalid.source", "invalid.site")
1613            .await
1614            .finish()
1615            .unwrap();
1616
1617        k9::snapshot!(
1618            resolved.params.additional_connection_limits,
1619            r#"
1620{
1621    "first": 10,
1622    "second": 32,
1623}
1624"#
1625        );
1626        k9::snapshot!(
1627            resolved.params.additional_message_rate_throttles,
1628            r#"
1629{
1630    "second": 100/h,
1631}
1632"#
1633        );
1634    }
1635
1636    #[tokio::test]
1637    async fn test_provider_multi_hostname() {
1638        let shaping = make_shaping_configs(&[r#"
1639[provider."yahoo"]
1640match=[{HostName="mta5.am0.yahoodns.net"},{HostName="mta6.am0.yahoodns.net"},{HostName="mta7.am0.yahoodns.net"}]
1641enable_tls = "Required"
1642        "#])
1643        .await;
1644
1645        let resolved = shaping
1646            .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1647            .await
1648            .finish()
1649            .unwrap();
1650
1651        k9::assert_equal!(
1652            resolved.params.enable_tls,
1653            crate::egress_path::Tls::Required
1654        );
1655        k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1656    }
1657
1658    #[tokio::test]
1659    async fn test_provider_multi_suffix() {
1660        let shaping = make_shaping_configs(&[r#"
1661[provider."yahoo"]
1662match=[{MXSuffix="mta5.am0.yahoodns.net"},{MXSuffix="mta6.am0.yahoodns.net"},{MXSuffix="mta7.am0.yahoodns.net"}]
1663enable_tls = "Required"
1664        "#])
1665        .await;
1666
1667        let resolved = shaping
1668            .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1669            .await
1670            .finish()
1671            .unwrap();
1672
1673        k9::assert_equal!(
1674            resolved.params.enable_tls,
1675            crate::egress_path::Tls::Required
1676        );
1677        k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1678    }
1679
1680    #[tokio::test]
1681    async fn test_provider() {
1682        let shaping = make_shaping_configs(&[r#"
1683[provider."Office 365"]
1684match=[{MXSuffix=".olc.protection.outlook.com"},{DomainSuffix=".outlook.com"}]
1685enable_tls = "Required"
1686provider_connection_limit = 10
1687provider_max_message_rate = "120/s"
1688
1689[provider."Office 365".sources."new-source"]
1690provider_source_selection_rate = "500/d,max_burst=1"
1691        "#])
1692        .await;
1693
1694        let resolved = shaping
1695            .get_egress_path_config("outlook.com", "invalid.source", "invalid.site")
1696            .await
1697            .finish()
1698            .unwrap();
1699
1700        k9::assert_equal!(
1701            resolved.params.enable_tls,
1702            crate::egress_path::Tls::Required
1703        );
1704        k9::assert_equal!(resolved.params.provider_name.unwrap(), "Office 365");
1705
1706        k9::snapshot!(
1707            resolved.params.additional_connection_limits,
1708            r#"
1709{
1710    "shaping-provider-Office 365-invalid.source-limit": 10,
1711}
1712"#
1713        );
1714        k9::snapshot!(
1715            resolved.params.additional_message_rate_throttles,
1716            r#"
1717{
1718    "shaping-provider-Office 365-invalid.source-rate": 120/s,
1719}
1720"#
1721        );
1722        assert!(resolved.params.source_selection_rate.is_none());
1723        assert!(resolved.params.additional_source_selection_rates.is_empty());
1724
1725        let resolved = shaping
1726            .get_egress_path_config("outlook.com", "new-source", "invalid.site")
1727            .await
1728            .finish()
1729            .unwrap();
1730        assert!(resolved.params.source_selection_rate.is_none());
1731        k9::snapshot!(
1732            resolved.params.additional_source_selection_rates,
1733            r#"
1734{
1735    "shaping-provider-Office 365-new-source-selection-rate": 500/d,max_burst=1,
1736}
1737"#
1738        );
1739    }
1740
1741    #[tokio::test]
1742    async fn test_rule_matching() {
1743        let shaping = make_shaping_configs(&[r#"
1744[["default".automation]]
1745regex="default"
1746action = {SetConfig={name="connection_limit", value=1}}
1747duration = "1hr"
1748
1749["fake.site"]
1750_treat_domain_name_as_site_name = true
1751
1752[["fake.site".automation]]
1753regex="fake_rollup"
1754action = {SetConfig={name="connection_limit", value=2}}
1755duration = "1hr"
1756
1757["woot.provider"]
1758mx_rollup = false
1759
1760[["woot.provider".automation]]
1761regex="woot_domain"
1762action = {SetConfig={name="connection_limit", value=2}}
1763duration = "1hr"
1764
1765[provider."provider"]
1766match=[{DomainSuffix=".provider"}]
1767
1768[[provider."provider".automation]]
1769regex="provider"
1770action = {SetConfig={name="connection_limit", value=3}}
1771duration = "1hr"
1772match_internal = true
1773
1774"#])
1775        .await;
1776
1777        eprintln!("{:?}", shaping.inner.warnings);
1778
1779        fn make_record(content: &str, recipient: &str, site: &str) -> JsonLogRecord {
1780            JsonLogRecord {
1781                kind: RecordType::TransientFailure,
1782                id: String::new(),
1783                sender: String::new(),
1784                recipient: vec![recipient.to_string()],
1785                queue: String::new(),
1786                site: site.to_string(),
1787                size: 0,
1788                response: Response {
1789                    code: 400,
1790                    command: None,
1791                    enhanced_code: None,
1792                    content: content.to_string(),
1793                },
1794                peer_address: None,
1795                timestamp: Default::default(),
1796                created: Default::default(),
1797                num_attempts: 1,
1798                bounce_classification: Default::default(),
1799                egress_pool: None,
1800                egress_source: None,
1801                source_address: None,
1802                feedback_report: None,
1803                meta: Default::default(),
1804                headers: Default::default(),
1805                delivery_protocol: None,
1806                reception_protocol: None,
1807                nodeid: Uuid::default(),
1808                tls_cipher: None,
1809                tls_protocol_version: None,
1810                tls_peer_subject_name: None,
1811                provider_name: None,
1812                session_id: None,
1813            }
1814        }
1815
1816        let matches = shaping
1817            .match_rules(&make_record("default", "user@example.com", "dummy_site"))
1818            .await
1819            .unwrap();
1820        k9::assert_equal!(
1821            matches[0].regex[0].to_string(),
1822            "default",
1823            "matches against default automation rule"
1824        );
1825
1826        let matches = shaping
1827            .match_rules(&make_record(
1828                "KumoMTA internal: default",
1829                "user@example.com",
1830                "dummy_site",
1831            ))
1832            .await
1833            .unwrap();
1834        assert!(matches.is_empty(), "internal bounce should not match");
1835
1836        let matches = shaping
1837            .match_rules(&make_record(
1838                "woot_domain",
1839                "user@woot.provider",
1840                "dummy_site",
1841            ))
1842            .await
1843            .unwrap();
1844        k9::assert_equal!(
1845            matches[0].regex[0].to_string(),
1846            "woot_domain",
1847            "matches against domain rule with mx_rollup=false"
1848        );
1849
1850        let matches = shaping
1851            .match_rules(&make_record("fake_rollup", "user@fake.rollup", "fake.site"))
1852            .await
1853            .unwrap();
1854        k9::assert_equal!(
1855            matches[0].regex[0].to_string(),
1856            "fake_rollup",
1857            "matches against domain rule with mx_rollup=true"
1858        );
1859
1860        let matches = shaping
1861            .match_rules(&make_record("provider", "user@woot.provider", "dummy_site"))
1862            .await
1863            .unwrap();
1864        k9::assert_equal!(
1865            matches[0].regex[0].to_string(),
1866            "provider",
1867            "matches against provider rule"
1868        );
1869
1870        let matches = shaping
1871            .match_rules(&make_record(
1872                "KumoMTA internal: provider",
1873                "user@woot.provider",
1874                "dummy_site",
1875            ))
1876            .await
1877            .unwrap();
1878        k9::assert_equal!(
1879            matches[0].regex[0].to_string(),
1880            "provider",
1881            "internal response matches against provider rule"
1882        );
1883    }
1884
1885    #[tokio::test]
1886    async fn test_defaults() {
1887        let shaping = make_shaping_configs(&[
1888            r#"
1889["default"]
1890connection_limit = 10
1891max_connection_rate = "100/min"
1892max_deliveries_per_connection = 100
1893max_message_rate = "100/s"
1894idle_timeout = "60s"
1895data_timeout = "30s"
1896data_dot_timeout = "60s"
1897enable_tls = "Opportunistic"
1898consecutive_connection_failures_before_delay = 100
1899
1900[["default".automation]]
1901regex=[
1902        '/Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/',
1903        '/All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/',
1904        '/has been temporarily rate limited due to IP reputation/',
1905        '/Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/',
1906        '/Server busy\. Please try again later from/'
1907]
1908action = [
1909        {SetConfig={name="max_message_rate", value="1/minute"}},
1910        {SetConfig={name="connection_limit", value=1}}
1911]
1912duration = "90m"
1913
1914[["default".automation]]
1915regex="KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site"
1916action = {SetConfig={name="enable_tls", value="Disabled"}}
1917duration = "30 days"
1918
1919["gmail.com"]
1920max_deliveries_per_connection = 50
1921connection_limit = 5
1922enable_tls = "Required"
1923consecutive_connection_failures_before_delay = 5
1924
1925["yahoo.com"]
1926max_deliveries_per_connection = 20
1927
1928[["yahoo.com".automation]]
1929regex = "\\[TS04\\]"
1930action = "Suspend"
1931duration = "2 hours"
1932
1933["comcast.net"]
1934connection_limit = 25
1935max_deliveries_per_connection = 250
1936enable_tls = "Required"
1937idle_timeout = "30s"
1938consecutive_connection_failures_before_delay = 24
1939
1940["mail.com"]
1941max_deliveries_per_connection = 100
1942
1943["orange.fr"]
1944connection_limit = 3
1945
1946["smtp.mailgun.com"]
1947connection_limit = 7000
1948max_deliveries_per_connection = 3
1949
1950["example.com"]
1951mx_rollup = false
1952max_deliveries_per_connection = 100
1953connection_limit = 3
1954
1955["example.com".sources."my source name"]
1956connection_limit = 5
1957        "#,
1958        ])
1959        .await;
1960
1961        let default = shaping
1962            .get_egress_path_config("invalid.domain", "invalid.source", "invalid.site")
1963            .await
1964            .finish()
1965            .unwrap();
1966        k9::snapshot!(
1967            default,
1968            r#"
1969MergedEntry {
1970    params: EgressPathConfig {
1971        connection_limit: 10,
1972        additional_connection_limits: {},
1973        enable_tls: Opportunistic,
1974        enable_mta_sts: true,
1975        enable_dane: false,
1976        enable_pipelining: true,
1977        enable_rset: true,
1978        tls_prefer_openssl: false,
1979        tls_certificate: None,
1980        tls_private_key: None,
1981        openssl_cipher_list: None,
1982        openssl_cipher_suites: None,
1983        openssl_options: None,
1984        rustls_cipher_suites: [],
1985        client_timeouts: SmtpClientTimeouts {
1986            connect_timeout: 60s,
1987            banner_timeout: 60s,
1988            ehlo_timeout: 300s,
1989            mail_from_timeout: 300s,
1990            rcpt_to_timeout: 300s,
1991            data_timeout: 30s,
1992            data_dot_timeout: 60s,
1993            rset_timeout: 5s,
1994            idle_timeout: 60s,
1995            starttls_timeout: 5s,
1996            auth_timeout: 60s,
1997        },
1998        system_shutdown_timeout: None,
1999        max_ready: 1024,
2000        consecutive_connection_failures_before_delay: 100,
2001        smtp_port: 25,
2002        smtp_auth_plain_username: None,
2003        smtp_auth_plain_password: None,
2004        allow_smtp_auth_plain_without_tls: false,
2005        allow_smtp_auth_plain_without_valid_certificate: false,
2006        max_message_rate: Some(
2007            100/s,
2008        ),
2009        additional_message_rate_throttles: {},
2010        source_selection_rate: None,
2011        additional_source_selection_rates: {},
2012        max_connection_rate: Some(
2013            100/m,
2014        ),
2015        max_deliveries_per_connection: 100,
2016        max_recipients_per_batch: 100,
2017        prohibited_hosts: {
2018            "0.0.0.0",
2019            "127.0.0.0/8",
2020            "::/127",
2021        },
2022        skip_hosts: {},
2023        ip_lookup_strategy: Ipv4AndIpv6,
2024        ehlo_domain: None,
2025        aggressive_connection_opening: false,
2026        refresh_interval: 60s,
2027        refresh_strategy: Ttl,
2028        dispatcher_wakeup_strategy: Aggressive,
2029        maintainer_wakeup_strategy: Aggressive,
2030        provider_name: None,
2031        remember_broken_tls: None,
2032        opportunistic_tls_reconnect_on_failed_handshake: false,
2033        use_lmtp: false,
2034        reconnect_strategy: ConnectNextHost,
2035        readyq_pool_name: None,
2036        low_memory_reduction_policy: ShrinkDataAndMeta,
2037        no_memory_reduction_policy: ShrinkDataAndMeta,
2038        try_next_host_on_transport_error: false,
2039        ignore_8bit_checks: false,
2040        dispatcher_progress_watchdog_timeout: None,
2041    },
2042    sources: {},
2043    automation: [
2044        Rule {
2045            regex: [
2046                Regex(
2047                    /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2048                ),
2049                Regex(
2050                    /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2051                ),
2052                Regex(
2053                    /has been temporarily rate limited due to IP reputation/,
2054                ),
2055                Regex(
2056                    /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2057                ),
2058                Regex(
2059                    /Server busy\. Please try again later from/,
2060                ),
2061            ],
2062            action: [
2063                SetConfig(
2064                    EgressPathConfigValue {
2065                        name: "max_message_rate",
2066                        value: HashableTomlValue {
2067                            value: String(
2068                                "1/minute",
2069                            ),
2070                        },
2071                    },
2072                ),
2073                SetConfig(
2074                    EgressPathConfigValue {
2075                        name: "connection_limit",
2076                        value: HashableTomlValue {
2077                            value: Integer(
2078                                1,
2079                            ),
2080                        },
2081                    },
2082                ),
2083            ],
2084            trigger: Immediate,
2085            duration: 5400s,
2086            was_rollup: false,
2087            match_internal: false,
2088        },
2089        Rule {
2090            regex: [
2091                Regex(
2092                    KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2093                ),
2094            ],
2095            action: [
2096                SetConfig(
2097                    EgressPathConfigValue {
2098                        name: "enable_tls",
2099                        value: HashableTomlValue {
2100                            value: String(
2101                                "Disabled",
2102                            ),
2103                        },
2104                    },
2105                ),
2106            ],
2107            trigger: Immediate,
2108            duration: 2592000s,
2109            was_rollup: false,
2110            match_internal: false,
2111        },
2112    ],
2113}
2114"#
2115        );
2116
2117        let example_com = shaping
2118            .get_egress_path_config("example.com", "invalid.source", "invalid.site")
2119            .await
2120            .finish()
2121            .unwrap();
2122        k9::snapshot!(
2123            example_com,
2124            r#"
2125MergedEntry {
2126    params: EgressPathConfig {
2127        connection_limit: 3,
2128        additional_connection_limits: {},
2129        enable_tls: Opportunistic,
2130        enable_mta_sts: true,
2131        enable_dane: false,
2132        enable_pipelining: true,
2133        enable_rset: true,
2134        tls_prefer_openssl: false,
2135        tls_certificate: None,
2136        tls_private_key: None,
2137        openssl_cipher_list: None,
2138        openssl_cipher_suites: None,
2139        openssl_options: None,
2140        rustls_cipher_suites: [],
2141        client_timeouts: SmtpClientTimeouts {
2142            connect_timeout: 60s,
2143            banner_timeout: 60s,
2144            ehlo_timeout: 300s,
2145            mail_from_timeout: 300s,
2146            rcpt_to_timeout: 300s,
2147            data_timeout: 30s,
2148            data_dot_timeout: 60s,
2149            rset_timeout: 5s,
2150            idle_timeout: 60s,
2151            starttls_timeout: 5s,
2152            auth_timeout: 60s,
2153        },
2154        system_shutdown_timeout: None,
2155        max_ready: 1024,
2156        consecutive_connection_failures_before_delay: 100,
2157        smtp_port: 25,
2158        smtp_auth_plain_username: None,
2159        smtp_auth_plain_password: None,
2160        allow_smtp_auth_plain_without_tls: false,
2161        allow_smtp_auth_plain_without_valid_certificate: false,
2162        max_message_rate: Some(
2163            100/s,
2164        ),
2165        additional_message_rate_throttles: {},
2166        source_selection_rate: None,
2167        additional_source_selection_rates: {},
2168        max_connection_rate: Some(
2169            100/m,
2170        ),
2171        max_deliveries_per_connection: 100,
2172        max_recipients_per_batch: 100,
2173        prohibited_hosts: {
2174            "0.0.0.0",
2175            "127.0.0.0/8",
2176            "::/127",
2177        },
2178        skip_hosts: {},
2179        ip_lookup_strategy: Ipv4AndIpv6,
2180        ehlo_domain: None,
2181        aggressive_connection_opening: false,
2182        refresh_interval: 60s,
2183        refresh_strategy: Ttl,
2184        dispatcher_wakeup_strategy: Aggressive,
2185        maintainer_wakeup_strategy: Aggressive,
2186        provider_name: None,
2187        remember_broken_tls: None,
2188        opportunistic_tls_reconnect_on_failed_handshake: false,
2189        use_lmtp: false,
2190        reconnect_strategy: ConnectNextHost,
2191        readyq_pool_name: None,
2192        low_memory_reduction_policy: ShrinkDataAndMeta,
2193        no_memory_reduction_policy: ShrinkDataAndMeta,
2194        try_next_host_on_transport_error: false,
2195        ignore_8bit_checks: false,
2196        dispatcher_progress_watchdog_timeout: None,
2197    },
2198    sources: {
2199        "my source name": EgressPathConfig {
2200            connection_limit: 5,
2201            additional_connection_limits: {},
2202            enable_tls: Opportunistic,
2203            enable_mta_sts: true,
2204            enable_dane: false,
2205            enable_pipelining: true,
2206            enable_rset: true,
2207            tls_prefer_openssl: false,
2208            tls_certificate: None,
2209            tls_private_key: None,
2210            openssl_cipher_list: None,
2211            openssl_cipher_suites: None,
2212            openssl_options: None,
2213            rustls_cipher_suites: [],
2214            client_timeouts: SmtpClientTimeouts {
2215                connect_timeout: 60s,
2216                banner_timeout: 60s,
2217                ehlo_timeout: 300s,
2218                mail_from_timeout: 300s,
2219                rcpt_to_timeout: 300s,
2220                data_timeout: 300s,
2221                data_dot_timeout: 300s,
2222                rset_timeout: 5s,
2223                idle_timeout: 5s,
2224                starttls_timeout: 5s,
2225                auth_timeout: 60s,
2226            },
2227            system_shutdown_timeout: None,
2228            max_ready: 1024,
2229            consecutive_connection_failures_before_delay: 100,
2230            smtp_port: 25,
2231            smtp_auth_plain_username: None,
2232            smtp_auth_plain_password: None,
2233            allow_smtp_auth_plain_without_tls: false,
2234            allow_smtp_auth_plain_without_valid_certificate: false,
2235            max_message_rate: None,
2236            additional_message_rate_throttles: {},
2237            source_selection_rate: None,
2238            additional_source_selection_rates: {},
2239            max_connection_rate: None,
2240            max_deliveries_per_connection: 1024,
2241            max_recipients_per_batch: 100,
2242            prohibited_hosts: {
2243                "0.0.0.0",
2244                "127.0.0.0/8",
2245                "::/127",
2246            },
2247            skip_hosts: {},
2248            ip_lookup_strategy: Ipv4AndIpv6,
2249            ehlo_domain: None,
2250            aggressive_connection_opening: false,
2251            refresh_interval: 60s,
2252            refresh_strategy: Ttl,
2253            dispatcher_wakeup_strategy: Aggressive,
2254            maintainer_wakeup_strategy: Aggressive,
2255            provider_name: None,
2256            remember_broken_tls: None,
2257            opportunistic_tls_reconnect_on_failed_handshake: false,
2258            use_lmtp: false,
2259            reconnect_strategy: ConnectNextHost,
2260            readyq_pool_name: None,
2261            low_memory_reduction_policy: ShrinkDataAndMeta,
2262            no_memory_reduction_policy: ShrinkDataAndMeta,
2263            try_next_host_on_transport_error: false,
2264            ignore_8bit_checks: false,
2265            dispatcher_progress_watchdog_timeout: None,
2266        },
2267    },
2268    automation: [
2269        Rule {
2270            regex: [
2271                Regex(
2272                    /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2273                ),
2274                Regex(
2275                    /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2276                ),
2277                Regex(
2278                    /has been temporarily rate limited due to IP reputation/,
2279                ),
2280                Regex(
2281                    /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2282                ),
2283                Regex(
2284                    /Server busy\. Please try again later from/,
2285                ),
2286            ],
2287            action: [
2288                SetConfig(
2289                    EgressPathConfigValue {
2290                        name: "max_message_rate",
2291                        value: HashableTomlValue {
2292                            value: String(
2293                                "1/minute",
2294                            ),
2295                        },
2296                    },
2297                ),
2298                SetConfig(
2299                    EgressPathConfigValue {
2300                        name: "connection_limit",
2301                        value: HashableTomlValue {
2302                            value: Integer(
2303                                1,
2304                            ),
2305                        },
2306                    },
2307                ),
2308            ],
2309            trigger: Immediate,
2310            duration: 5400s,
2311            was_rollup: false,
2312            match_internal: false,
2313        },
2314        Rule {
2315            regex: [
2316                Regex(
2317                    KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2318                ),
2319            ],
2320            action: [
2321                SetConfig(
2322                    EgressPathConfigValue {
2323                        name: "enable_tls",
2324                        value: HashableTomlValue {
2325                            value: String(
2326                                "Disabled",
2327                            ),
2328                        },
2329                    },
2330                ),
2331            ],
2332            trigger: Immediate,
2333            duration: 2592000s,
2334            was_rollup: false,
2335            match_internal: false,
2336        },
2337    ],
2338}
2339"#
2340        );
2341
2342        // The site name here will need to be updated if yahoo changes
2343        // their MX records
2344        let yahoo_com = shaping
2345            .get_egress_path_config(
2346                "yahoo.com",
2347                "invalid.source",
2348                "(mta5|mta6|mta7).am0.yahoodns.net",
2349            )
2350            .await
2351            .finish()
2352            .unwrap();
2353        k9::snapshot!(
2354            yahoo_com,
2355            r#"
2356MergedEntry {
2357    params: EgressPathConfig {
2358        connection_limit: 10,
2359        additional_connection_limits: {},
2360        enable_tls: Opportunistic,
2361        enable_mta_sts: true,
2362        enable_dane: false,
2363        enable_pipelining: true,
2364        enable_rset: true,
2365        tls_prefer_openssl: false,
2366        tls_certificate: None,
2367        tls_private_key: None,
2368        openssl_cipher_list: None,
2369        openssl_cipher_suites: None,
2370        openssl_options: None,
2371        rustls_cipher_suites: [],
2372        client_timeouts: SmtpClientTimeouts {
2373            connect_timeout: 60s,
2374            banner_timeout: 60s,
2375            ehlo_timeout: 300s,
2376            mail_from_timeout: 300s,
2377            rcpt_to_timeout: 300s,
2378            data_timeout: 30s,
2379            data_dot_timeout: 60s,
2380            rset_timeout: 5s,
2381            idle_timeout: 60s,
2382            starttls_timeout: 5s,
2383            auth_timeout: 60s,
2384        },
2385        system_shutdown_timeout: None,
2386        max_ready: 1024,
2387        consecutive_connection_failures_before_delay: 100,
2388        smtp_port: 25,
2389        smtp_auth_plain_username: None,
2390        smtp_auth_plain_password: None,
2391        allow_smtp_auth_plain_without_tls: false,
2392        allow_smtp_auth_plain_without_valid_certificate: false,
2393        max_message_rate: Some(
2394            100/s,
2395        ),
2396        additional_message_rate_throttles: {},
2397        source_selection_rate: None,
2398        additional_source_selection_rates: {},
2399        max_connection_rate: Some(
2400            100/m,
2401        ),
2402        max_deliveries_per_connection: 20,
2403        max_recipients_per_batch: 100,
2404        prohibited_hosts: {
2405            "0.0.0.0",
2406            "127.0.0.0/8",
2407            "::/127",
2408        },
2409        skip_hosts: {},
2410        ip_lookup_strategy: Ipv4AndIpv6,
2411        ehlo_domain: None,
2412        aggressive_connection_opening: false,
2413        refresh_interval: 60s,
2414        refresh_strategy: Ttl,
2415        dispatcher_wakeup_strategy: Aggressive,
2416        maintainer_wakeup_strategy: Aggressive,
2417        provider_name: None,
2418        remember_broken_tls: None,
2419        opportunistic_tls_reconnect_on_failed_handshake: false,
2420        use_lmtp: false,
2421        reconnect_strategy: ConnectNextHost,
2422        readyq_pool_name: None,
2423        low_memory_reduction_policy: ShrinkDataAndMeta,
2424        no_memory_reduction_policy: ShrinkDataAndMeta,
2425        try_next_host_on_transport_error: false,
2426        ignore_8bit_checks: false,
2427        dispatcher_progress_watchdog_timeout: None,
2428    },
2429    sources: {},
2430    automation: [
2431        Rule {
2432            regex: [
2433                Regex(
2434                    /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2435                ),
2436                Regex(
2437                    /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2438                ),
2439                Regex(
2440                    /has been temporarily rate limited due to IP reputation/,
2441                ),
2442                Regex(
2443                    /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2444                ),
2445                Regex(
2446                    /Server busy\. Please try again later from/,
2447                ),
2448            ],
2449            action: [
2450                SetConfig(
2451                    EgressPathConfigValue {
2452                        name: "max_message_rate",
2453                        value: HashableTomlValue {
2454                            value: String(
2455                                "1/minute",
2456                            ),
2457                        },
2458                    },
2459                ),
2460                SetConfig(
2461                    EgressPathConfigValue {
2462                        name: "connection_limit",
2463                        value: HashableTomlValue {
2464                            value: Integer(
2465                                1,
2466                            ),
2467                        },
2468                    },
2469                ),
2470            ],
2471            trigger: Immediate,
2472            duration: 5400s,
2473            was_rollup: false,
2474            match_internal: false,
2475        },
2476        Rule {
2477            regex: [
2478                Regex(
2479                    KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2480                ),
2481            ],
2482            action: [
2483                SetConfig(
2484                    EgressPathConfigValue {
2485                        name: "enable_tls",
2486                        value: HashableTomlValue {
2487                            value: String(
2488                                "Disabled",
2489                            ),
2490                        },
2491                    },
2492                ),
2493            ],
2494            trigger: Immediate,
2495            duration: 2592000s,
2496            was_rollup: false,
2497            match_internal: false,
2498        },
2499        Rule {
2500            regex: [
2501                Regex(
2502                    \[TS04\],
2503                ),
2504            ],
2505            action: [
2506                Suspend,
2507            ],
2508            trigger: Immediate,
2509            duration: 7200s,
2510            was_rollup: false,
2511            match_internal: false,
2512        },
2513    ],
2514}
2515"#
2516        );
2517    }
2518
2519    #[tokio::test]
2520    async fn test_load_default_shaping_toml() {
2521        Shaping::merge_files(
2522            &["../../assets/policy-extras/shaping.toml".into()],
2523            &ShapingMergeOptions::default(),
2524        )
2525        .await
2526        .unwrap();
2527    }
2528}