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