diff --git a/src/config.rs b/src/config.rs index 2c748e4..ccf702f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -119,15 +119,24 @@ pub fn write_atomic(path: &Path, config: &Config) -> std::io::Result<()> { Ok(()) } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Clone, PartialEq)] pub struct Config { - pub domain: String, + /// Configured domains, in operator-declared order. `domains[0]` is the + /// default domain; bare local-parts resolve against it. Non-empty, + /// lowercased, RFC 1035 valid, deduplicated. Always serialized as + /// `domains = [...]` (canonical shape). The legacy single-domain field + /// `domain = "..."` is accepted on read but never written back. + pub domains: Vec, #[serde(default = "default_data_dir")] pub data_dir: PathBuf, - #[serde(default = "default_dkim_selector")] - pub dkim_selector: String, + /// Top-level DKIM selector. `None` resolves to the built-in default + /// `"aimx"` via [`Config::default_dkim_selector`]. Per-domain + /// `[domains.] dkim_selector = "..."` overrides this for that + /// domain (resolution order: per-domain -> top-level -> `"aimx"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dkim_selector: Option, /// Default trust policy applied to every mailbox that does not set /// its own `trust`. Allowed values: `"none"` (default) or `"verified"`. @@ -142,9 +151,33 @@ pub struct Config { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub trusted_senders: Vec, + /// Mailboxes keyed by full address (FQDN), e.g. `"info@a.com"`. Legacy + /// local-part-keyed mailboxes (`[mailboxes.info]`) are accepted on + /// read and preserved as the operator-friendly key in the in-memory + /// map; only the `address` field is validated against `domains`. The + /// canonical serialized shape on rewrite is FQDN-keyed, and on-disk + /// re-keying of legacy installs is performed by the upgrade + /// migration on first daemon start — not during config load. #[serde(default)] pub mailboxes: HashMap, + /// Per-domain overrides loaded from `[domain.""]` sub-tables. + /// Each key must appear in [`Config::domains`]; dangling sub-tables + /// produce a load error. Resolution helpers that consume this map + /// land later in the multi-domain track. + /// + /// The TOML key is `domain` (singular) so it doesn't collide with the + /// `domains = [...]` array at the top level — TOML cannot let one key + /// be both an array and a table. The singular form mirrors the + /// existing `aimx domain` / `aimx domains` clap alias pattern. + #[serde( + default, + rename = "domain", + skip_serializing_if = "HashMap::is_empty", + serialize_with = "serialize_per_domain" + )] + pub per_domain: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] pub verify_host: Option, @@ -173,6 +206,383 @@ impl Config { pub fn effective_signature(&self) -> &str { self.signature.as_deref().unwrap_or(DEFAULT_SIGNATURE) } + + /// Default domain — the first entry of [`Config::domains`]. Bare + /// local-parts on `aimx send` and bare-local-part mailbox keys in + /// the legacy config shape resolve against this value. + /// + /// Panics if `domains` is empty, but `Config::load` rejects empty + /// `domains` before this is reachable on a loaded `Config`. + pub fn default_domain(&self) -> &str { + &self.domains[0] + } + + /// Resolve the global default DKIM selector. Returns the top-level + /// `dkim_selector` when set, otherwise the built-in default `"aimx"`. + /// Per-domain overrides are layered on top of this by later + /// callers that consume `per_domain`. + pub fn default_dkim_selector(&self) -> &str { + self.dkim_selector.as_deref().unwrap_or("aimx") + } +} + +/// Per-domain override carried under `[domain.""]` sub-tables. +/// All fields are optional; absent fields fall back to the top-level +/// `Config` defaults. Resolution helpers that consume this map are +/// added later as the multi-domain runtime wiring lands. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +pub struct DomainOverride { + /// Per-domain outbound signature override. Falls back to + /// [`Config::signature`] when `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, + + /// Per-domain DKIM selector override. Falls back to the top-level + /// `dkim_selector` and then to the built-in `"aimx"` when `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dkim_selector: Option, + + /// Per-domain trust policy override. Allowed values mirror the + /// top-level [`Config::trust`] (`"none"` or `"verified"`); validated + /// at load time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust: Option, + + /// Per-domain trusted-senders override. Replace semantics: a `Some` + /// here entirely replaces the top-level list (no merging), matching + /// the per-mailbox layer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_senders: Option>, +} + +fn serialize_per_domain( + map: &HashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeMap; + let mut sorted: Vec<(&String, &DomainOverride)> = map.iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + let mut m = serializer.serialize_map(Some(sorted.len()))?; + for (k, v) in sorted { + m.serialize_entry(k, v)?; + } + m.end() +} + +/// Accepts either the legacy single-domain field (`domain = "..."`, a +/// string) or the canonical multi-domain per-domain sub-table +/// (`[domain.""] ...`, a map of per-domain overrides). The two +/// shapes share the TOML key `domain`; an untagged enum disambiguates on +/// the value's runtime shape. +#[derive(Deserialize)] +#[serde(untagged)] +enum DomainField { + Legacy(String), + PerDomain(HashMap), +} + +/// Internal raw shape for `Config` deserialization. Mirrors the on-disk +/// TOML one-for-one, accepting either the legacy single-domain field +/// (`domain = "..."`) or the canonical multi-domain field +/// (`domains = [...]`). The conversion to `Config` is implemented in +/// `Deserialize for Config` and applies all of the multi-domain +/// validation rules. +#[derive(Deserialize)] +struct RawConfig { + /// Carries either the legacy `domain = "..."` string or the + /// canonical `[domain.""]` per-domain sub-table. Disambiguated + /// inside [`Config::from_raw`]. + #[serde(default)] + domain: Option, + #[serde(default)] + domains: Option>, + #[serde(default = "default_data_dir")] + data_dir: PathBuf, + #[serde(default)] + dkim_selector: Option, + #[serde(default = "default_trust")] + trust: String, + #[serde(default)] + trusted_senders: Vec, + #[serde(default)] + mailboxes: HashMap, + #[serde(default)] + verify_host: Option, + #[serde(default)] + enable_ipv6: bool, + #[serde(default)] + signature: Option, + #[serde(default)] + upgrade: Option, +} + +impl<'de> Deserialize<'de> for Config { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawConfig::deserialize(deserializer)?; + Self::from_raw(raw).map_err(serde::de::Error::custom) + } +} + +impl Config { + /// Normalize a `RawConfig` (either legacy or canonical shape on disk) + /// into the canonical in-memory `Config`. Applies every multi-domain + /// rule that does not depend on filesystem state: + /// + /// - rejects mixing `domain` + `domains`; + /// - lowercases + RFC 1035-validates each domain; + /// - rejects empty `domains`, duplicates (case-insensitive), and + /// syntactically invalid entries; + /// - preserves legacy local-part mailbox keys as-is in the in-memory + /// map (on-disk re-keying to FQDN is handled later by the upgrade + /// migration, not here); + /// - enforces the key/`address` invariant for FQDN-keyed mailboxes + /// and the address-domain-in-`domains` invariant for every + /// mailbox; + /// - rejects dangling `[domain.""]` sub-tables whose key isn't + /// in `domains`. + /// + /// Mailbox owner resolution, hook-shape validation, and the legacy + /// schema rejections all run later in [`Config::load`]. + fn from_raw(raw: RawConfig) -> Result { + let RawConfig { + domain, + domains, + data_dir, + dkim_selector, + trust, + trusted_senders, + mailboxes, + verify_host, + enable_ipv6, + signature, + upgrade, + } = raw; + + let (legacy_domain, per_domain) = match domain { + None => (None, HashMap::new()), + Some(DomainField::Legacy(s)) => (Some(s), HashMap::new()), + Some(DomainField::PerDomain(m)) => (None, m), + }; + + let domains = normalize_domains_field(legacy_domain, domains)?; + + // Lowercase per-domain keys so lookups against `domains[0]` + // (already lowercased) stay consistent. + let mut per_domain_norm: HashMap = + HashMap::with_capacity(per_domain.len()); + for (k, v) in per_domain { + per_domain_norm.insert(k.to_ascii_lowercase(), v); + } + + // Every per-domain sub-table key must reference a configured + // domain. Dangling overrides are a load error. + for key in per_domain_norm.keys() { + if !domains.iter().any(|d| d == key) { + return Err(format!( + "per-domain override `[domain.\"{key}\"]` references a \ + domain not listed in `domains = [...]`; add '{key}' to \ + `domains` or remove the sub-table" + )); + } + } + + let mailboxes = normalize_mailboxes_field(mailboxes, &domains)?; + + Ok(Config { + domains, + data_dir, + dkim_selector, + trust, + trusted_senders, + mailboxes, + per_domain: per_domain_norm, + verify_host, + enable_ipv6, + signature, + upgrade, + }) + } +} + +/// Resolve the `domain` (legacy) and `domains` (canonical) fields into a +/// single canonical `Vec` with lowercasing, dedup, RFC 1035 +/// validation, and the mixed-shape rejection rule. +fn normalize_domains_field( + legacy_domain: Option, + canonical_domains: Option>, +) -> Result, String> { + let raw = match (legacy_domain, canonical_domains) { + (Some(_), Some(_)) => { + return Err( + "specify either 'domain' (singular, legacy) or 'domains' (plural), not both" + .to_string(), + ); + } + (Some(d), None) => vec![d], + (None, Some(list)) => list, + (None, None) => { + return Err( + "missing required field: set either `domain = \"...\"` (legacy) \ + or `domains = [\"a.com\", ...]` (canonical) in config.toml" + .to_string(), + ); + } + }; + + if raw.is_empty() { + return Err("`domains` must contain at least one entry".to_string()); + } + + let mut out: Vec = Vec::with_capacity(raw.len()); + for entry in raw { + let lower = entry.trim().to_ascii_lowercase(); + if !is_valid_domain_syntax(&lower) { + return Err(format!( + "domain '{entry}' is not a valid RFC 1035 hostname (lowercased to '{lower}')" + )); + } + if out.iter().any(|existing| existing == &lower) { + return Err(format!( + "duplicate domain '{lower}' in `domains` (case-insensitive)" + )); + } + out.push(lower); + } + Ok(out) +} + +/// Predicate for RFC 1035 hostname syntax suitable for an email domain. +/// +/// Accepts: ASCII letters, digits, hyphens, dots; labels must be 1-63 +/// characters and may not start or end with a hyphen; total length +/// 1-253; at least two labels (a single bare label is not a valid email +/// domain). Hyphen-only labels and empty labels (double dots) are +/// rejected. +pub fn is_valid_domain_syntax(s: &str) -> bool { + if s.is_empty() || s.len() > 253 { + return false; + } + let mut label_count = 0; + for label in s.split('.') { + label_count += 1; + if label.is_empty() || label.len() > 63 { + return false; + } + if label.starts_with('-') || label.ends_with('-') { + return false; + } + if !label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return false; + } + } + label_count >= 2 +} + +/// Normalize mailboxes loaded from TOML. Two key shapes are accepted: +/// +/// - FQDN-keyed: `[mailboxes."info@a.com"]` with `address = "info@a.com"`. +/// Key and `address` must match (case-insensitive on the domain). +/// - Legacy local-part-keyed: `[mailboxes.info]` (no `@` in the key). +/// On read, the operator-chosen friendly key is preserved as-is in +/// the in-memory map; the `address` field is required to be a full +/// FQDN whose domain appears in `domains`. A later one-shot upgrade +/// migration re-keys legacy entries to FQDN on disk; until then, the +/// in-memory map preserves the existing single-domain naming so the +/// runtime data plane (ingest, storage paths, mailbox CRUD) keeps +/// working unchanged. +/// +/// In every case `address` must: +/// - carry an `@` (no bare local-part addresses); +/// - reference a domain listed in `domains`; +/// - be lowercased on the domain portion. +fn normalize_mailboxes_field( + raw: HashMap, + domains: &[String], +) -> Result, String> { + let mut out: HashMap = HashMap::with_capacity(raw.len()); + + for (key, mut mb) in raw { + // `address` is required to be a full FQDN. Lowercase the domain + // portion so equality checks are case-insensitive on the + // domain (matching DNS semantics) and downstream code only ever + // sees one shape. + if !mb.address.contains('@') { + return Err(format!( + "mailbox '{key}' has `address = \"{addr}\"` which is not a \ + full @ address", + addr = mb.address, + )); + } + mb.address = normalize_fqdn(&mb.address); + + if key.contains('@') { + // FQDN-keyed mailbox in the source TOML. Validate key == + // address (case-insensitive on the domain portion). + let normalized_key = normalize_fqdn(&key); + if mb.address != normalized_key { + return Err(format!( + "mailbox `[mailboxes.\"{key}\"]` has `address = \"{addr}\"` \ + which does not match its key (expected '{normalized_key}'); \ + rename the key or update the address", + addr = mb.address, + )); + } + // The address's domain must be in `domains`. + require_address_domain_in_domains(&normalized_key, &mb.address, domains)?; + if out.insert(normalized_key.clone(), mb).is_some() { + return Err(format!( + "duplicate mailbox key '{normalized_key}' in `[mailboxes]`" + )); + } + } else { + // Legacy local-part-keyed mailbox. Preserve the + // operator-friendly key as the in-memory map key (a later + // upgrade migration rewrites these to FQDN on disk; the + // in-memory shape stays operator-friendly so single-domain + // runtime paths keep working unchanged). The `address` + // field is still required to reference a configured domain. + require_address_domain_in_domains(&key, &mb.address, domains)?; + if out.insert(key.clone(), mb).is_some() { + return Err(format!("duplicate mailbox key '{key}' in `[mailboxes]`")); + } + } + } + Ok(out) +} + +fn require_address_domain_in_domains( + key: &str, + address: &str, + domains: &[String], +) -> Result<(), String> { + let domain_part = address.rsplit_once('@').map(|(_, d)| d).unwrap_or_default(); + if !domains.iter().any(|d| d == domain_part) { + return Err(format!( + "mailbox '{key}' references domain '{domain_part}' which is not \ + in `domains = [...]`; add the domain or update the mailbox address" + )); + } + Ok(()) +} + +/// Lowercase the domain portion of an FQDN-shaped address (everything +/// after the last `@`). The local part is preserved as written; the +/// domain is lowercased to match DNS case-insensitivity. The wildcard +/// catchall syntax (`*@`) is supported. +fn normalize_fqdn(addr: &str) -> String { + match addr.rsplit_once('@') { + Some((local, domain)) => format!("{local}@{}", domain.to_ascii_lowercase()), + None => addr.to_string(), + } } /// Operator-overridable knobs for `aimx upgrade`. Today only the manifest URL @@ -324,14 +734,16 @@ pub struct MailboxConfig { } impl MailboxConfig { - /// True iff this mailbox's `address` is the wildcard catchall for - /// the configured `domain` (`*@domain`). Used by - /// [`check_hook_owner_invariant`] to relax the owner-match rule for - /// the catchall (hooks run as `aimx-catchall` there, not the - /// mailbox owner). + /// True iff this mailbox's `address` is the wildcard catchall + /// (`*@`) for any of the configured domains in + /// [`Config::domains`]. Used by [`check_hook_owner_invariant`] to + /// relax the owner-match rule for the catchall (hooks run as + /// `aimx-catchall` there, not the mailbox owner). pub fn is_catchall(&self, config: &Config) -> bool { - self.address - .eq_ignore_ascii_case(&format!("*@{}", config.domain)) + config + .domains + .iter() + .any(|d| self.address.eq_ignore_ascii_case(&format!("*@{d}"))) } /// Resolve the mailbox's `owner` via [`validate_run_as`]. Returns @@ -466,10 +878,6 @@ fn default_data_dir() -> PathBuf { PathBuf::from(DEFAULT_DATA_DIR) } -fn default_dkim_selector() -> String { - "aimx".to_string() -} - /// Allowed values for `Config::trust` and per-mailbox `MailboxConfig::trust`. /// Validated at config load time (`Config::load`) so typos fail fast with a /// clear error rather than silently fail-closed at runtime via @@ -858,8 +1266,8 @@ fn validate_mailbox_owners(config: &Config) -> Result, String> if mb.allow_root_catchall && !is_catchall { return Err(format!( "mailbox '{name}' sets allow_root_catchall=true but is not a \ - catchall (*@{domain}); remove the flag or change the address", - domain = config.domain, + catchall (*@ for any configured domain); remove the \ + flag or change the address", )); } @@ -904,6 +1312,16 @@ fn validate_trust_values(config: &Config) -> Result<(), String> { )); } } + for (domain, override_) in &config.per_domain { + if let Some(t) = override_.trust.as_deref() + && !VALID_TRUST_VALUES.contains(&t) + { + return Err(format!( + "invalid trust value '{t}' on per-domain override \ + `[domain.\"{domain}\"]`: expected one of {VALID_TRUST_VALUES:?}" + )); + } + } Ok(()) } @@ -1119,7 +1537,7 @@ impl ConfigHandle { impl std::fmt::Debug for ConfigHandle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ConfigHandle") - .field("domain", &self.load().domain) + .field("domains", &self.load().domains) .finish_non_exhaustive() } } @@ -1626,4 +2044,477 @@ cmd = ["echo", "hi"] "error should call out non-absolute cmd[0]: {err}" ); } + + // --- Multi-domain schema: domains field + legacy `domain` back-compat --- + + #[test] + fn load_legacy_domain_single_string_normalizes_to_domains_vec() { + let toml = r#" +domain = "example.com" + +[mailboxes.info] +address = "info@example.com" +owner = "ops" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.domains, vec!["example.com".to_string()]); + assert_eq!(cfg.default_domain(), "example.com"); + } + + #[test] + fn load_canonical_domains_vec_parses_verbatim() { + let toml = r#" +domains = ["a.com", "b.com"] + +[mailboxes."info@a.com"] +address = "info@a.com" +owner = "ops" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.domains, vec!["a.com".to_string(), "b.com".to_string()]); + assert_eq!(cfg.default_domain(), "a.com"); + } + + #[test] + fn load_rejects_mixed_legacy_and_canonical_domain_fields() { + // The legacy `domain = "..."` and canonical `[domain.""]` + // share a TOML key. When both shapes are present, the canonical + // shape "wins" because TOML tables outrank a leaf value. We + // exercise the explicit mixed shape via `domain` + `domains` + // together, which is the real backwards-compat pitfall. + let toml = r#" +domain = "a.com" +domains = ["a.com", "b.com"] +"#; + let err = toml::from_str::(toml).unwrap_err().to_string(); + assert!( + err.contains( + "specify either 'domain' (singular, legacy) or 'domains' (plural), not both" + ), + "error should match the exact wording: {err}" + ); + } + + #[test] + fn load_rejects_empty_domains_list() { + let toml = r#" +domains = [] +"#; + let err = toml::from_str::(toml).unwrap_err().to_string(); + assert!( + err.contains("must contain at least one entry"), + "error should reject empty domains: {err}" + ); + } + + #[test] + fn load_rejects_duplicate_domains_case_insensitive() { + let toml = r#" +domains = ["a.com", "A.com"] +"#; + let err = toml::from_str::(toml).unwrap_err().to_string(); + assert!( + err.contains("duplicate domain"), + "error should reject case-insensitive duplicates: {err}" + ); + } + + #[test] + fn load_lowercases_domain_entries() { + let toml = r#" +domains = ["A.COM", "B.com"] + +[mailboxes."info@a.com"] +address = "info@a.com" +owner = "ops" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.domains, vec!["a.com".to_string(), "b.com".to_string()]); + } + + #[test] + fn load_rejects_syntactically_invalid_domain() { + for bad in &["a..com", "a com", "-foo.com", "foo-.com", "single"] { + let toml = format!("domains = [\"{bad}\"]\n"); + let err = toml::from_str::(&toml).unwrap_err().to_string(); + assert!( + err.contains("RFC 1035") || err.contains("not a valid"), + "domain '{bad}' should reject as syntactically invalid: {err}" + ); + } + } + + #[test] + fn load_missing_domain_and_domains_is_load_error() { + let toml = r#" +trust = "none" +"#; + let err = toml::from_str::(toml).unwrap_err().to_string(); + assert!( + err.contains("missing required field"), + "error should hint at the missing field: {err}" + ); + } + + // --- Multi-domain schema: FQDN-keyed mailboxes + legacy local-part --- + + #[test] + fn load_fqdn_keyed_mailbox_parses() { + let toml = r#" +domains = ["a.com"] + +[mailboxes."info@a.com"] +address = "info@a.com" +owner = "ops" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(cfg.mailboxes.contains_key("info@a.com")); + assert_eq!(cfg.mailboxes["info@a.com"].address, "info@a.com"); + } + + #[test] + fn load_legacy_local_part_mailbox_keeps_key_and_validates_address() { + // The legacy key shape stays as-is in the in-memory map; a + // later upgrade migration is what rewrites the on-disk key to + // FQDN. The `address` field must still reference a domain in + // `domains`. + let toml = r#" +domains = ["a.com"] + +[mailboxes.info] +address = "info@a.com" +owner = "ops" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(cfg.mailboxes.contains_key("info")); + assert_eq!(cfg.mailboxes["info"].address, "info@a.com"); + } + + #[test] + fn load_rejects_fqdn_key_mismatched_address() { + let toml = r#" +domains = ["a.com"] + +[mailboxes."info@a.com"] +address = "support@a.com" +owner = "ops" +"#; + let err = toml::from_str::(toml).unwrap_err().to_string(); + assert!( + err.contains("does not match its key"), + "error should reject FQDN key/address mismatch: {err}" + ); + } + + #[test] + fn load_rejects_mailbox_address_domain_not_in_domains() { + let toml = r#" +domains = ["a.com"] + +[mailboxes."info@b.com"] +address = "info@b.com" +owner = "ops" +"#; + let err = toml::from_str::(toml).unwrap_err().to_string(); + assert!( + err.contains("not in `domains = [...]`"), + "error should reject foreign-domain mailbox: {err}" + ); + } + + #[test] + fn is_catchall_recognizes_any_configured_domain() { + let toml = r#" +domains = ["a.com", "b.com"] + +[mailboxes."*@b.com"] +address = "*@b.com" +owner = "aimx-catchall" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + let mb = &cfg.mailboxes["*@b.com"]; + assert!(mb.is_catchall(&cfg)); + } + + #[test] + fn load_accepts_per_domain_catchalls_as_distinct_mailboxes() { + let toml = r#" +domains = ["a.com", "b.com"] + +[mailboxes."*@a.com"] +address = "*@a.com" +owner = "aimx-catchall" + +[mailboxes."*@b.com"] +address = "*@b.com" +owner = "aimx-catchall" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(cfg.mailboxes.contains_key("*@a.com")); + assert!(cfg.mailboxes.contains_key("*@b.com")); + assert!(cfg.mailboxes["*@a.com"].is_catchall(&cfg)); + assert!(cfg.mailboxes["*@b.com"].is_catchall(&cfg)); + } + + #[test] + fn load_accepts_same_local_part_on_different_domains() { + let toml = r#" +domains = ["a.com", "b.com"] + +[mailboxes."info@a.com"] +address = "info@a.com" +owner = "ops" + +[mailboxes."info@b.com"] +address = "info@b.com" +owner = "ops" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.mailboxes.len(), 2); + assert!(cfg.mailboxes.contains_key("info@a.com")); + assert!(cfg.mailboxes.contains_key("info@b.com")); + } + + #[test] + fn load_accepts_mixed_legacy_and_fqdn_keys_in_same_file() { + let toml = r#" +domains = ["a.com"] + +[mailboxes.info] +address = "info@a.com" +owner = "ops" + +[mailboxes."support@a.com"] +address = "support@a.com" +owner = "ops" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(cfg.mailboxes.contains_key("info")); + assert!(cfg.mailboxes.contains_key("support@a.com")); + } + + // --- Multi-domain schema: per-domain `[domain.""]` sub-tables --- + + #[test] + fn load_empty_per_domain_sub_table_parses() { + let toml = r#" +domains = ["a.com"] + +[domain."a.com"] +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + let over = cfg.per_domain.get("a.com").expect("override present"); + assert!(over.signature.is_none()); + assert!(over.dkim_selector.is_none()); + assert!(over.trust.is_none()); + assert!(over.trusted_senders.is_none()); + } + + #[test] + fn load_full_per_domain_sub_table_parses() { + let toml = r#" +domains = ["a.com", "b.com"] + +[domain."b.com"] +signature = "Sent from B Corp" +dkim_selector = "s2025" +trust = "verified" +trusted_senders = ["*@trusted-partner.com"] +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + let over = cfg.per_domain.get("b.com").expect("override present"); + assert_eq!(over.signature.as_deref(), Some("Sent from B Corp")); + assert_eq!(over.dkim_selector.as_deref(), Some("s2025")); + assert_eq!(over.trust.as_deref(), Some("verified")); + assert_eq!( + over.trusted_senders.as_deref(), + Some(&["*@trusted-partner.com".to_string()][..]) + ); + } + + #[test] + fn load_partial_per_domain_sub_table_parses() { + let toml = r#" +domains = ["a.com"] + +[domain."a.com"] +signature = "Sent from A" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + let over = &cfg.per_domain["a.com"]; + assert_eq!(over.signature.as_deref(), Some("Sent from A")); + assert!(over.dkim_selector.is_none()); + } + + #[test] + fn load_rejects_dangling_per_domain_sub_table() { + let toml = r#" +domains = ["a.com"] + +[domain."c.com"] +signature = "Sent from C" +"#; + let err = toml::from_str::(toml).unwrap_err().to_string(); + assert!( + err.contains("references a domain not listed in"), + "error should reject dangling sub-table: {err}" + ); + } + + #[test] + fn load_rejects_invalid_trust_value_in_per_domain_override() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.toml"); + write_cfg( + &path, + r#" +domains = ["a.com"] + +[domain."a.com"] +trust = "totally-trusted" + +[mailboxes.catchall] +address = "*@a.com" +owner = "aimx-catchall" +"#, + ); + let err = Config::load(&path).unwrap_err().to_string(); + assert!( + err.contains("invalid trust value 'totally-trusted'"), + "error should reject bad per-domain trust value: {err}" + ); + } + + #[test] + fn default_dkim_selector_falls_back_to_aimx_when_unset() { + let toml = r#" +domains = ["a.com"] +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(cfg.dkim_selector.is_none()); + assert_eq!(cfg.default_dkim_selector(), "aimx"); + } + + #[test] + fn default_dkim_selector_respects_top_level_override() { + let toml = r#" +domains = ["a.com"] +dkim_selector = "s2025" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!(cfg.default_dkim_selector(), "s2025"); + } + + /// `validate_hooks` still runs on multi-domain configs and still + /// rejects the legacy hook fields. + #[test] + fn load_still_rejects_legacy_hook_fields_on_multi_domain_config() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("config.toml"); + write_cfg( + &path, + r#" +domains = ["a.com"] + +[mailboxes."support@a.com"] +address = "support@a.com" +owner = "ops" + +[[mailboxes."support@a.com".hooks]] +event = "on_receive" +cmd = ["/bin/true"] +run_as = "ops" +"#, + ); + let err = Config::load(&path).unwrap_err().to_string(); + assert!( + err.contains("`run_as`") && err.contains("removed"), + "error should still reject legacy `run_as`: {err}" + ); + } + + // --- Fixture snapshot tests for every legal config shape --- + + fn fixtures_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("config") + } + + #[test] + fn fixture_legacy_v1_single_domain_parses() { + let path = fixtures_dir().join("legacy-v1-single-domain.toml"); + let cfg = Config::load_ignore_warnings(&path).unwrap(); + assert_eq!(cfg.domains, vec!["mydomain.com".to_string()]); + assert_eq!(cfg.default_domain(), "mydomain.com"); + // Legacy local-part keys are preserved in-memory at this + // layer; the on-disk rewrite to FQDN happens during the later + // upgrade migration. + assert!(cfg.mailboxes.contains_key("info")); + assert!(cfg.mailboxes.contains_key("support")); + assert!(cfg.per_domain.is_empty()); + assert!(cfg.dkim_selector.is_none()); + assert_eq!(cfg.default_dkim_selector(), "aimx"); + } + + #[test] + fn fixture_legacy_v1_with_catchall_parses() { + let path = fixtures_dir().join("legacy-v1-with-catchall.toml"); + let cfg = Config::load_ignore_warnings(&path).unwrap(); + assert_eq!(cfg.domains, vec!["mydomain.com".to_string()]); + let catchall = cfg.mailboxes.get("catchall").expect("catchall present"); + assert!(catchall.is_catchall(&cfg)); + } + + #[test] + fn fixture_canonical_single_domain_parses() { + let path = fixtures_dir().join("canonical-single-domain.toml"); + let cfg = Config::load_ignore_warnings(&path).unwrap(); + assert_eq!(cfg.domains, vec!["mydomain.com".to_string()]); + assert!(cfg.mailboxes.contains_key("info@mydomain.com")); + assert!(cfg.mailboxes.contains_key("*@mydomain.com")); + assert!(cfg.per_domain.is_empty()); + } + + #[test] + fn fixture_canonical_two_domains_parses() { + let path = fixtures_dir().join("canonical-two-domains.toml"); + let cfg = Config::load_ignore_warnings(&path).unwrap(); + assert_eq!(cfg.domains, vec!["a.com".to_string(), "b.com".to_string()]); + assert_eq!(cfg.default_domain(), "a.com"); + assert!(cfg.mailboxes.contains_key("info@a.com")); + assert!(cfg.mailboxes.contains_key("support@b.com")); + assert!(cfg.mailboxes.contains_key("*@a.com")); + assert!(cfg.mailboxes.contains_key("*@b.com")); + assert_eq!(cfg.mailboxes.len(), 4); + assert!(cfg.per_domain.is_empty()); + } + + #[test] + fn fixture_canonical_two_domains_with_overrides_parses() { + let path = fixtures_dir().join("canonical-two-domains-with-overrides.toml"); + let cfg = Config::load_ignore_warnings(&path).unwrap(); + assert_eq!(cfg.domains, vec!["a.com".to_string(), "b.com".to_string()]); + let b_over = cfg.per_domain.get("b.com").expect("b.com override present"); + assert_eq!(b_over.signature.as_deref(), Some("Sent from B Corp")); + assert_eq!(b_over.dkim_selector.as_deref(), Some("s2025")); + assert_eq!(b_over.trust.as_deref(), Some("verified")); + assert_eq!( + b_over.trusted_senders.as_deref(), + Some(&["*@trusted-partner.com".to_string()][..]) + ); + assert!(!cfg.per_domain.contains_key("a.com")); + } + + #[test] + fn fixture_mixed_legacy_fqdn_parses() { + let path = fixtures_dir().join("mixed-legacy-fqdn.toml"); + let cfg = Config::load_ignore_warnings(&path).unwrap(); + assert_eq!(cfg.domains, vec!["mydomain.com".to_string()]); + // Legacy local-part key preserved as-is, FQDN key preserved as-is. + assert!(cfg.mailboxes.contains_key("info")); + assert!(cfg.mailboxes.contains_key("support@mydomain.com")); + assert_eq!(cfg.mailboxes["info"].address, "info@mydomain.com"); + } } diff --git a/src/doctor.rs b/src/doctor.rs index ac1cee8..17d5b92 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -179,10 +179,10 @@ pub fn gather_status_with_ops( let dns = gather_dns_section(config, net); StatusInfo { - domain: config.domain.clone(), + domain: config.default_domain().to_string(), data_dir: config.data_dir.to_string_lossy().to_string(), config_path: crate::config::config_path().to_string_lossy().to_string(), - dkim_selector: config.dkim_selector.clone(), + dkim_selector: config.default_dkim_selector().to_string(), dkim_key_present, smtp_running, client_version, @@ -214,18 +214,20 @@ fn gather_dns_section(config: &Config, net: &dyn NetworkOps) -> Option Option Option Config { force_sandbox_fallback(); Config { - domain: "test.com".to_string(), + domains: vec!["test.com".to_string()], data_dir: PathBuf::from("/tmp/aimx-test"), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes: HashMap::new(), + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/hook_handler.rs b/src/hook_handler.rs index c350af7..c80f695 100644 --- a/src/hook_handler.rs +++ b/src/hook_handler.rs @@ -424,12 +424,13 @@ mod tests { }, ); Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/hook_list_handler.rs b/src/hook_list_handler.rs index e45f7b7..071d786 100644 --- a/src/hook_list_handler.rs +++ b/src/hook_list_handler.rs @@ -184,12 +184,13 @@ mod tests { }, ); Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/ingest.rs b/src/ingest.rs index 831e1fe..e48e188 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -1341,12 +1341,13 @@ mod tests { }, ); Config { - domain: "test.com".to_string(), + domains: vec!["test.com".to_string()], data_dir: tmp.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -3019,12 +3020,13 @@ mod tests { }, ); let config = Config { - domain: "test.com".to_string(), + domains: vec!["test.com".to_string()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/mailbox.rs b/src/mailbox.rs index 731693c..759aafb 100644 --- a/src/mailbox.rs +++ b/src/mailbox.rs @@ -211,7 +211,7 @@ pub fn create_mailbox( } let new_mb = MailboxConfig { - address: format!("{name}@{}", config.domain), + address: format!("{name}@{}", config.default_domain()), owner: owner.to_string(), hooks: vec![], trust: None, @@ -553,7 +553,7 @@ fn resolve_create_owner( } return Ok(o.to_string()); } - let address = format!("{name}@{domain}", domain = config.domain); + let address = format!("{name}@{}", config.default_domain()); crate::setup::prompt_mailbox_owner(&address, sys) } @@ -1065,12 +1065,13 @@ mod tests { ); } Config { - domain: "agent.example.com".into(), + domains: vec!["agent.example.com".into()], data_dir: std::path::PathBuf::from("/tmp/test"), - dkim_selector: "aimx".into(), + dkim_selector: Some("aimx".into()), trust: "none".into(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/mailbox_handler.rs b/src/mailbox_handler.rs index 79f39fa..db27dc5 100644 --- a/src/mailbox_handler.rs +++ b/src/mailbox_handler.rs @@ -381,7 +381,7 @@ fn handle_create( // resolved-side chown uses this MailboxConfig so a future // `config.toml` reload would still agree with the on-disk owner. let mut new_config: Config = (*current).clone(); - let address = format!("{name}@{}", new_config.domain); + let address = format!("{name}@{}", new_config.default_domain()); let mb_cfg = MailboxConfig { address, owner: owner.to_string(), @@ -638,12 +638,13 @@ mod tests { }, ); Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/mailbox_list_handler.rs b/src/mailbox_list_handler.rs index d89cf61..4d9d9a2 100644 --- a/src/mailbox_list_handler.rs +++ b/src/mailbox_list_handler.rs @@ -241,12 +241,13 @@ mod tests { }, ); Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/main.rs b/src/main.rs index 0ef0f26..09e97b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,9 +197,12 @@ fn dispatch_with_config( match cmd { Command::Ingest { rcpt } => ingest::run(&rcpt, config), Command::Hooks(cmd) => hooks::run(cmd, config), - Command::DkimKeygen { selector, force } => { - dkim::run_keygen(&config::dkim_dir(), &config.domain, &selector, force) - } + Command::DkimKeygen { selector, force } => dkim::run_keygen( + &config::dkim_dir(), + config.default_domain(), + &selector, + force, + ), Command::Serve { bind, tls_cert, diff --git a/src/mcp.rs b/src/mcp.rs index f757937..7f0e086 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1907,12 +1907,13 @@ mod auth_tests { ); } Config { - domain: "agent.example.com".into(), + domains: vec!["agent.example.com".into()], data_dir: tmp.to_path_buf(), - dkim_selector: "aimx".into(), + dkim_selector: Some("aimx".into()), trust: "none".into(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/portcheck.rs b/src/portcheck.rs index 2eb18e8..7ee8872 100644 --- a/src/portcheck.rs +++ b/src/portcheck.rs @@ -404,7 +404,7 @@ mod tests { fn config_without_verify_address_parses() { let toml_str = "domain = \"test.com\"\n[mailboxes]\n"; let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.domain, "test.com"); + assert_eq!(config.domains, vec!["test.com".to_string()]); } #[test] diff --git a/src/send_handler.rs b/src/send_handler.rs index 7cc87f5..16e7fe7 100644 --- a/src/send_handler.rs +++ b/src/send_handler.rs @@ -96,7 +96,7 @@ where // MAILBOX-CREATE/DELETE that lands after this point still runs; the // swap just doesn't affect the decision for *this* particular send. let config = ctx.config_handle.load(); - let primary_domain = config.domain.as_str(); + let primary_domain = config.default_domain(); let mailboxes = config.mailboxes.iter().map(|(name, mb)| { ( name.clone(), @@ -805,12 +805,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: dir.clone(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -1425,12 +1426,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".into(), + domains: vec!["example.com".into()], data_dir: data_dir.path().to_path_buf(), - dkim_selector: "aimx".into(), + dkim_selector: Some("aimx".into()), trust: "none".into(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -1489,12 +1491,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: data_dir.clone(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature, diff --git a/src/serve.rs b/src/serve.rs index 494626e..e778031 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -494,9 +494,10 @@ async fn run_serve( // Compare the on-disk public key to the DNS-published `p=` value. // Never fatal. DNS may not have propagated yet after a fresh setup. let resolver = HickoryDkimResolver; - let outcome = - run_dkim_startup_check(&resolver, &config.domain, &config.dkim_selector, &dkim_root); - log_dkim_startup_check(&outcome, &config.domain, &config.dkim_selector); + let primary_domain = config.default_domain().to_string(); + let selector = config.default_dkim_selector().to_string(); + let outcome = run_dkim_startup_check(&resolver, &primary_domain, &selector, &dkim_root); + log_dkim_startup_check(&outcome, &primary_domain, &selector); // Build the SendContext shared across every per-connection UDS task. // @@ -519,7 +520,7 @@ async fn run_serve( } None => Arc::new(LettreTransport::new( config.enable_ipv6, - config.domain.clone(), + primary_domain.clone(), )), }; @@ -528,7 +529,7 @@ async fn run_serve( // through this same handle so MAILBOX-CREATE/DELETE is reflected // everywhere at once on a successful atomic `config.toml` write. let data_dir = config.data_dir.clone(); - let dkim_selector = config.dkim_selector.clone(); + let dkim_selector = selector.clone(); let config_handle = ConfigHandle::new(config); let send_ctx = Arc::new(SendContext { @@ -1565,12 +1566,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "test.local".to_string(), + domains: vec!["test.local".to_string()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -1876,12 +1878,13 @@ mod tests { }, ); crate::config::Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: None, trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -2037,12 +2040,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -2306,7 +2310,7 @@ mod tests { let cfg = build_test_config(tmp.path()); cfg.save(&path).unwrap(); let handle = ConfigHandle::new(Config::load_ignore_warnings(&path).unwrap()); - let before_domain = handle.load().domain.clone(); + let before_domains = handle.load().domains.clone(); // Corrupt the file — not valid TOML. std::fs::write(&path, b"this is ][ not toml").unwrap(); @@ -2317,7 +2321,7 @@ mod tests { msg.to_lowercase().contains("toml") || msg.to_lowercase().contains("expected"), "error should mention TOML parse issue: {msg}" ); - assert_eq!(handle.load().domain, before_domain); + assert_eq!(handle.load().domains, before_domains); assert_eq!(handle.load().mailboxes.len(), 2); } diff --git a/src/setup.rs b/src/setup.rs index ac3987c..e9c8bab 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1475,9 +1475,9 @@ pub fn finalize_setup( let config_path = crate::config::config_path(); let config = if config_path.exists() { let mut cfg = Config::load_ignore_warnings(&config_path)?; - if cfg.domain != domain { - let old_domain = cfg.domain.clone(); - cfg.domain = domain.to_string(); + if cfg.default_domain() != domain { + let old_domain = cfg.default_domain().to_string(); + cfg.domains = vec![domain.to_string()]; for mailbox in cfg.mailboxes.values_mut() { if mailbox.address.ends_with(&format!("@{old_domain}")) { let local_part = mailbox @@ -1520,12 +1520,13 @@ pub fn finalize_setup( }, ); let cfg = Config { - domain: domain.to_string(), + domains: vec![domain.to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: dkim_selector.to_string(), + dkim_selector: Some(dkim_selector.to_string()), trust: default_trust, trusted_senders: default_trusted_senders, mailboxes, + per_domain: HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -2496,7 +2497,7 @@ pub fn run_setup( let config_path = crate::config::config_path(); let (dkim_selector, enable_ipv6) = if config_path.exists() { match Config::load_ignore_warnings(&config_path) { - Ok(c) => (c.dkim_selector, c.enable_ipv6), + Ok(c) => (c.default_dkim_selector().to_string(), c.enable_ipv6), Err(_) => ("aimx".to_string(), false), } } else { @@ -4352,7 +4353,7 @@ owner = "aimx-catchall" assert!(tmp.path().join("dkim/public.key").exists()); let config = Config::load_resolved_ignore_warnings().unwrap(); - assert_eq!(config.domain, "test.example.com"); + assert_eq!(config.default_domain(), "test.example.com"); assert!(config.mailboxes.contains_key("catchall")); assert_eq!(config.mailboxes["catchall"].address, "*@test.example.com"); } @@ -4371,7 +4372,7 @@ owner = "aimx-catchall" assert_eq!(key1, key2); let config = Config::load_resolved_ignore_warnings().unwrap(); - assert_eq!(config.domain, "test.example.com"); + assert_eq!(config.default_domain(), "test.example.com"); assert!(config.mailboxes.contains_key("catchall")); } @@ -4449,12 +4450,13 @@ owner = "aimx-catchall" }, ); let config = Config { - domain: "test.example.com".to_string(), + domains: vec!["test.example.com".to_string()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -4507,7 +4509,7 @@ owner = "aimx-catchall" finalize_setup(tmp.path(), "new.example.com", "aimx", None).unwrap(); let config = Config::load_resolved_ignore_warnings().unwrap(); - assert_eq!(config.domain, "new.example.com"); + assert_eq!(config.default_domain(), "new.example.com"); let catchall = config.mailboxes.get("catchall").unwrap(); assert_eq!(catchall.address, "*@new.example.com"); } diff --git a/src/smtp/mod.rs b/src/smtp/mod.rs index cece0f0..bc493d8 100644 --- a/src/smtp/mod.rs +++ b/src/smtp/mod.rs @@ -140,7 +140,7 @@ impl SmtpServer { let tls_acceptor = self.tls_acceptor.clone(); // Re-read the hostname from the current snapshot so it // tracks any live Config swap. - let hostname = config_handle.load().domain.clone(); + let hostname = config_handle.load().default_domain().to_string(); let max_message_size = self.max_message_size; let idle_timeout = self.idle_timeout; let total_timeout = self.total_timeout; diff --git a/src/smtp/session.rs b/src/smtp/session.rs index 93684e0..6890d1b 100644 --- a/src/smtp/session.rs +++ b/src/smtp/session.rs @@ -374,10 +374,12 @@ impl SmtpSession { return "501 Syntax: RCPT TO:
\r\n".to_string(); } let config = self.params.config_handle.load(); - if !recipient_domain_matches(&addr, &config.domain) { + if !recipient_domain_matches(&addr, config.default_domain()) { eprintln!( "[{}] RCPT rejected (relay): recipient={} configured_domain={}", - self.params.peer_addr, addr, config.domain + self.params.peer_addr, + addr, + config.default_domain() ); return "550 5.7.1 relay not permitted\r\n".to_string(); } diff --git a/src/smtp/tests.rs b/src/smtp/tests.rs index 605e58c..c70e77b 100644 --- a/src/smtp/tests.rs +++ b/src/smtp/tests.rs @@ -79,12 +79,13 @@ fn test_config(data_dir: &std::path::Path) -> Config { }, ); Config { - domain: "test.local".to_string(), + domains: vec!["test.local".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -855,12 +856,13 @@ async fn test_ingest_failure_returns_451() { }, ); let config = Config { - domain: "test.local".to_string(), + domains: vec!["test.local".to_string()], data_dir: bad_data_dir, - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -1322,12 +1324,13 @@ fn test_config_no_catchall(data_dir: &std::path::Path) -> Config { }, ); Config { - domain: "test.local".to_string(), + domains: vec!["test.local".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -1413,12 +1416,13 @@ fn test_config_two_mailboxes(data_dir: &std::path::Path) -> Config { }, ); Config { - domain: "test.local".to_string(), + domains: vec!["test.local".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -1587,12 +1591,13 @@ fn test_config_with_slow_hook( }, ); Config { - domain: "test.local".to_string(), + domains: vec!["test.local".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/state_handler.rs b/src/state_handler.rs index c6da79d..52350c3 100644 --- a/src/state_handler.rs +++ b/src/state_handler.rs @@ -358,12 +358,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".to_string(), + domains: vec!["example.com".to_string()], data_dir: data_dir.to_path_buf(), - dkim_selector: "aimx".to_string(), + dkim_selector: Some("aimx".to_string()), trust: "none".to_string(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -705,12 +706,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".into(), + domains: vec!["example.com".into()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".into(), + dkim_selector: Some("aimx".into()), trust: "none".into(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -798,12 +800,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".into(), + domains: vec!["example.com".into()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".into(), + dkim_selector: Some("aimx".into()), trust: "none".into(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -894,12 +897,13 @@ mod tests { }, ); let config = crate::config::Config { - domain: "example.com".into(), + domains: vec!["example.com".into()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".into(), + dkim_selector: Some("aimx".into()), trust: "none".into(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, @@ -972,12 +976,13 @@ mod tests { ); } let config = crate::config::Config { - domain: "example.com".into(), + domains: vec!["example.com".into()], data_dir: tmp.path().to_path_buf(), - dkim_selector: "aimx".into(), + dkim_selector: Some("aimx".into()), trust: "none".into(), trusted_senders: vec![], mailboxes, + per_domain: std::collections::HashMap::new(), verify_host: None, enable_ipv6: false, signature: None, diff --git a/src/transport.rs b/src/transport.rs index d315903..33608af 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -467,11 +467,11 @@ mod tests { #[test] fn lettre_transport_helo_name_tracks_config_domain() { - // End-to-end: serve builds the transport from `config.domain`. + // End-to-end: serve builds the transport from the default domain. // The transport surfaces that value as its EHLO identity. use crate::config::Config; let cfg: Config = toml::from_str("domain = \"a.example\"\n[mailboxes]\n").unwrap(); - let t = LettreTransport::new(false, cfg.domain.clone()); + let t = LettreTransport::new(false, cfg.default_domain().to_string()); assert_eq!(t.helo_name(), "a.example"); } diff --git a/tests/fixtures/config/canonical-single-domain.toml b/tests/fixtures/config/canonical-single-domain.toml new file mode 100644 index 0000000..68fcd39 --- /dev/null +++ b/tests/fixtures/config/canonical-single-domain.toml @@ -0,0 +1,13 @@ +domains = ["mydomain.com"] + +[mailboxes."*@mydomain.com"] +address = "*@mydomain.com" +owner = "aimx-catchall" + +[mailboxes."info@mydomain.com"] +address = "info@mydomain.com" +owner = "ops" + +[mailboxes."support@mydomain.com"] +address = "support@mydomain.com" +owner = "ops" diff --git a/tests/fixtures/config/canonical-two-domains-with-overrides.toml b/tests/fixtures/config/canonical-two-domains-with-overrides.toml new file mode 100644 index 0000000..8fccc81 --- /dev/null +++ b/tests/fixtures/config/canonical-two-domains-with-overrides.toml @@ -0,0 +1,18 @@ +domains = ["a.com", "b.com"] +trust = "none" +trusted_senders = ["*@a-partner.com"] +signature = "Sent via AIMX" + +[domain."b.com"] +signature = "Sent from B Corp" +dkim_selector = "s2025" +trust = "verified" +trusted_senders = ["*@trusted-partner.com"] + +[mailboxes."info@a.com"] +address = "info@a.com" +owner = "ops" + +[mailboxes."support@b.com"] +address = "support@b.com" +owner = "ops" diff --git a/tests/fixtures/config/canonical-two-domains.toml b/tests/fixtures/config/canonical-two-domains.toml new file mode 100644 index 0000000..0ef0184 --- /dev/null +++ b/tests/fixtures/config/canonical-two-domains.toml @@ -0,0 +1,17 @@ +domains = ["a.com", "b.com"] + +[mailboxes."*@a.com"] +address = "*@a.com" +owner = "aimx-catchall" + +[mailboxes."*@b.com"] +address = "*@b.com" +owner = "aimx-catchall" + +[mailboxes."info@a.com"] +address = "info@a.com" +owner = "ops" + +[mailboxes."support@b.com"] +address = "support@b.com" +owner = "ops" diff --git a/tests/fixtures/config/legacy-v1-single-domain.toml b/tests/fixtures/config/legacy-v1-single-domain.toml new file mode 100644 index 0000000..b2cbc82 --- /dev/null +++ b/tests/fixtures/config/legacy-v1-single-domain.toml @@ -0,0 +1,13 @@ +domain = "mydomain.com" + +[mailboxes.info] +address = "info@mydomain.com" +owner = "ops" + +[mailboxes.support] +address = "support@mydomain.com" +owner = "ops" + +[mailboxes.alice] +address = "alice@mydomain.com" +owner = "ops" diff --git a/tests/fixtures/config/legacy-v1-with-catchall.toml b/tests/fixtures/config/legacy-v1-with-catchall.toml new file mode 100644 index 0000000..f3bc04b --- /dev/null +++ b/tests/fixtures/config/legacy-v1-with-catchall.toml @@ -0,0 +1,10 @@ +domain = "mydomain.com" +dkim_selector = "aimx" + +[mailboxes.catchall] +address = "*@mydomain.com" +owner = "aimx-catchall" + +[mailboxes.info] +address = "info@mydomain.com" +owner = "ops" diff --git a/tests/fixtures/config/mixed-legacy-fqdn.toml b/tests/fixtures/config/mixed-legacy-fqdn.toml new file mode 100644 index 0000000..d72f9cb --- /dev/null +++ b/tests/fixtures/config/mixed-legacy-fqdn.toml @@ -0,0 +1,9 @@ +domains = ["mydomain.com"] + +[mailboxes.info] +address = "info@mydomain.com" +owner = "ops" + +[mailboxes."support@mydomain.com"] +address = "support@mydomain.com" +owner = "ops"