diff --git a/crates/common/src/config/smtp/queue.rs b/crates/common/src/config/smtp/queue.rs index 963d63cb..2f7b292c 100644 --- a/crates/common/src/config/smtp/queue.rs +++ b/crates/common/src/config/smtp/queue.rs @@ -80,8 +80,9 @@ pub enum RoutingStrategy { Local, Mx(MxConfig), Relay(RelayConfig), - // inbuxa: handed to an inbuxa Relay over its link (relay spec, RL-24) - InbuxaRelay, + // inbuxa: handed to an inbuxa Relay over its link (relay spec, RL-24): + // one of the relays named, or any relay when none are + InbuxaRelay(Vec), } #[derive(Clone, Debug)] @@ -414,6 +415,13 @@ impl QueueConfig { .routing_strategy .insert(route.name, RoutingStrategy::Local); } + // inbuxa: relay spec, RL-24 + MtaRoute::InbuxaRelay(route) => { + queue.routing_strategy.insert( + route.name, + RoutingStrategy::InbuxaRelay(route.relays.into_iter().collect()), + ); + } } } diff --git a/crates/features/src/relay/outbound.rs b/crates/features/src/relay/outbound.rs index 107ca389..a44eca2f 100644 --- a/crates/features/src/relay/outbound.rs +++ b/crates/features/src/relay/outbound.rs @@ -21,7 +21,7 @@ use super::{ use inbuxa_relay_proto::types::Envelope; use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; use std::{ - collections::{BTreeMap, HashSet}, + collections::{BTreeMap, HashMap}, future::Future, pin::Pin, sync::{ @@ -74,8 +74,9 @@ struct State { known: bool, /// An enabled relay has `sendThrough` on (RL-24). send_through: bool, - /// Enabled relays not marked `inboundOnly` (RL-20). - may_send: HashSet, + /// Enabled relays not marked `inboundOnly` (RL-20), by id, with their + /// names as they are now: a rename doesn't restart the link. + may_send: HashMap, linked: BTreeMap>, } @@ -88,7 +89,7 @@ impl Senders { state.may_send = relays .iter() .filter(|r| r.enabled && !r.inbound_only) - .map(|r| r.id) + .map(|r| (r.id, r.name.trim().to_string())) .collect(); } @@ -133,14 +134,19 @@ impl Senders { } /// The relays to try for one message, in order: those this node is - /// linked to and that may send, each message starting with the next - /// one in turn (RL-20). - pub fn candidates(&self) -> Vec> { + /// linked to and that may send, of those `named` when a route names + /// some, each message starting with the next one in turn (RL-20, RL-24). + pub fn candidates(&self, named: &[String]) -> Vec> { let state = self.state.lock().expect("senders"); let mut linked: Vec> = state .linked .values() - .filter(|l| state.may_send.contains(&l.relay_id)) + .filter(|l| { + state + .may_send + .get(&l.relay_id) + .is_some_and(|name| named.is_empty() || named.iter().any(|n| n.trim() == name)) + }) .cloned() .collect(); if !linked.is_empty() { @@ -275,13 +281,13 @@ mod tests { ]); assert!(senders.sending()); // Nothing linked: nothing to try, and the mail waits - assert!(senders.candidates().is_empty()); + assert!(senders.candidates(&[]).is_empty()); for id in [1, 2, 3] { senders.link_up(linked(id)); } let mut firsts = Vec::new(); for _ in 0..4 { - let ids: Vec = senders.candidates().iter().map(|l| l.relay_id).collect(); + let ids: Vec = senders.candidates(&[]).iter().map(|l| l.relay_id).collect(); assert_eq!(ids.len(), 2, "{ids:?}"); assert!(!ids.contains(&2), "inboundOnly: {ids:?}"); firsts.push(ids[0]); @@ -298,8 +304,37 @@ mod tests { senders.link_up(old.clone()); senders.link_up(new.clone()); senders.link_down(&old); - assert_eq!(senders.candidates().len(), 1); + assert_eq!(senders.candidates(&[]).len(), 1); senders.link_down(&new); - assert!(senders.candidates().is_empty()); + assert!(senders.candidates(&[]).is_empty()); + } + + #[test] + fn a_route_names_its_relays() { + let senders = Senders::default(); + let mut renamed = relay(3, false, false); + renamed.name = "east".into(); + senders.set_relays(&[relay(1, false, false), relay(2, false, true), renamed]); + for id in [1, 2, 3] { + senders.link_up(linked(id)); + } + let ids = |named: &[&str]| -> Vec { + let named: Vec = named.iter().map(|n| n.to_string()).collect(); + let mut ids: Vec = senders + .candidates(&named) + .iter() + .map(|l| l.relay_id) + .collect(); + ids.sort(); + ids + }; + assert_eq!(ids(&[]), [1, 3]); + assert_eq!(ids(&["r1"]), [1]); + // By the name it has now, not the one it was linked under + assert_eq!(ids(&["east"]), [3]); + assert!(ids(&["r3"]).is_empty()); + // inboundOnly sends nothing, named or not + assert!(ids(&["r2"]).is_empty()); + assert_eq!(ids(&["r1", "east", "nowhere"]), [1, 3]); } } diff --git a/crates/registry/src/inbuxa_tests.rs b/crates/registry/src/inbuxa_tests.rs index 8ea4807a..bc989b67 100644 --- a/crates/registry/src/inbuxa_tests.rs +++ b/crates/registry/src/inbuxa_tests.rs @@ -9,10 +9,14 @@ use crate::{ pickle::{Pickle, PickledStream}, schema::{ - enums::OidcOpaqueTokens, + enums::{MtaRouteType, OidcOpaqueTokens}, prelude::{ObjectImpl, ObjectInner, ObjectType}, - structs::{Bootstrap, Directory, DirectoryBootstrap, OidcDirectory}, + structs::{ + Bootstrap, Directory, DirectoryBootstrap, MtaRoute, MtaRouteCommon, + MtaRouteInbuxaRelay, OidcDirectory, + }, }, + types::{EnumImpl, map::Map}, }; /// An OIDC directory as it was stored before the opaque-token fields. @@ -108,3 +112,39 @@ fn oidc_directories_saved_before_the_opaque_token_policy_keep_userinfo() { ); } } + +// Relay spec, RL-24: the inbuxaRelay route is a fourth route type, stored +// after the three there were, which read as before +#[test] +fn inbuxa_relay_routes_are_stored_after_the_others() { + let route = MtaRoute::InbuxaRelay(MtaRouteInbuxaRelay { + relays: Map::new(vec!["east".to_string(), "west".to_string()]), + name: "relays".to_string(), + description: Some("Through the east and west relays".to_string()), + }); + let bytes = route.to_pickled_vec(); + assert_eq!( + read(ObjectType::MtaRoute, &bytes), + ObjectInner::MtaRoute(route) + ); + + let local = MtaRoute::Local(MtaRouteCommon { + name: "local".to_string(), + description: None, + }); + let mut bytes = vec![0u8]; + 2u16.pickle(&mut bytes); + "local".to_string().pickle(&mut bytes); + None::.pickle(&mut bytes); + assert_eq!(local.to_pickled_vec(), bytes); + assert_eq!( + read(ObjectType::MtaRoute, &bytes), + ObjectInner::MtaRoute(local) + ); + + assert_eq!( + MtaRouteType::parse("InbuxaRelay"), + Some(MtaRouteType::InbuxaRelay) + ); + assert_eq!(MtaRouteType::from_id(3), Some(MtaRouteType::InbuxaRelay)); +} diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 8d333fc7..a4c43eec 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1448,6 +1448,8 @@ pub enum MtaRouteType { Mx = 0, Relay = 1, Local = 2, + // inbuxa: relay spec, RL-24 + InbuxaRelay = 3, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index 35e36eda..43d2b9e3 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -6508,6 +6508,7 @@ impl EnumImpl for MtaRouteType { b"Mx" => MtaRouteType::Mx, b"Relay" => MtaRouteType::Relay, b"Local" => MtaRouteType::Local, + b"InbuxaRelay" => MtaRouteType::InbuxaRelay, } } @@ -6516,6 +6517,7 @@ impl EnumImpl for MtaRouteType { MtaRouteType::Mx => "Mx", MtaRouteType::Relay => "Relay", MtaRouteType::Local => "Local", + MtaRouteType::InbuxaRelay => "InbuxaRelay", } } @@ -6528,11 +6530,12 @@ impl EnumImpl for MtaRouteType { 0 => Some(MtaRouteType::Mx), 1 => Some(MtaRouteType::Relay), 2 => Some(MtaRouteType::Local), + 3 => Some(MtaRouteType::InbuxaRelay), _ => None, } } - const COUNT: usize = 3; + const COUNT: usize = 4; } impl serde::Serialize for MtaRouteType { diff --git a/crates/registry/src/schema/properties.rs b/crates/registry/src/schema/properties.rs index 529c205c..052faaae 100644 --- a/crates/registry/src/schema/properties.rs +++ b/crates/registry/src/schema/properties.rs @@ -989,6 +989,8 @@ pub enum Property { IntrospectionUrl = 936, IntrospectionClientId = 937, IntrospectionClientSecret = 938, + // inbuxa: relay spec, RL-24 + Relays = 939, RequireClientRegistration = 615, RequireScopes = 608, RequireTls = 525, diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs index 0f53ecb0..adca09d9 100644 --- a/crates/registry/src/schema/properties_impl.rs +++ b/crates/registry/src/schema/properties_impl.rs @@ -1141,6 +1141,7 @@ impl EnumImpl for Property { b"introspectionUrl" => Property::IntrospectionUrl, b"introspectionClientId" => Property::IntrospectionClientId, b"introspectionClientSecret" => Property::IntrospectionClientSecret, + b"relays" => Property::Relays, b"requireClientRegistration" => Property::RequireClientRegistration, b"requireScopes" => Property::RequireScopes, b"requireTls" => Property::RequireTls, @@ -2085,6 +2086,7 @@ impl EnumImpl for Property { Property::IntrospectionUrl => "introspectionUrl", Property::IntrospectionClientId => "introspectionClientId", Property::IntrospectionClientSecret => "introspectionClientSecret", + Property::Relays => "relays", Property::RequireClientRegistration => "requireClientRegistration", Property::RequireScopes => "requireScopes", Property::RequireTls => "requireTls", @@ -3033,6 +3035,7 @@ impl EnumImpl for Property { 936 => Some(Property::IntrospectionUrl), 937 => Some(Property::IntrospectionClientId), 938 => Some(Property::IntrospectionClientSecret), + 939 => Some(Property::Relays), 615 => Some(Property::RequireClientRegistration), 608 => Some(Property::RequireScopes), 525 => Some(Property::RequireTls), @@ -3244,7 +3247,7 @@ impl EnumImpl for Property { } } - const COUNT: usize = 939; + const COUNT: usize = 940; } impl serde::Serialize for Property { diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs index acf2361d..779de1d3 100644 --- a/crates/registry/src/schema/structs.rs +++ b/crates/registry/src/schema/structs.rs @@ -3796,6 +3796,8 @@ pub enum MtaRoute { Mx(MtaRouteMx), Relay(MtaRouteRelay), Local(MtaRouteCommon), + // inbuxa: relay spec, RL-24 + InbuxaRelay(MtaRouteInbuxaRelay), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -3807,6 +3809,18 @@ pub struct MtaRouteCommon { pub description: Option, } +// inbuxa: through inbuxa Relays (relay spec, RL-24) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MtaRouteInbuxaRelay { + #[serde(rename = "relays")] + pub relays: Map, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "description")] + pub description: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct MtaRouteMx { diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 02c72ef8..e501961c 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -27860,6 +27860,7 @@ impl ObjectImpl for MtaRoute { MtaRoute::Mx(inner) => inner.validate(errors), MtaRoute::Relay(inner) => inner.validate(errors), MtaRoute::Local(inner) => inner.validate(errors), + MtaRoute::InbuxaRelay(inner) => inner.validate(errors), } } @@ -27874,6 +27875,9 @@ impl ObjectImpl for MtaRoute { MtaRoute::Local(object) => { object.index(i); } + MtaRoute::InbuxaRelay(object) => { + object.index(i); + } } } } @@ -27899,6 +27903,10 @@ impl Pickle for MtaRoute { 2u16.pickle(out); inner.pickle(out); } + MtaRoute::InbuxaRelay(inner) => { + 3u16.pickle(out); + inner.pickle(out); + } } } @@ -27907,6 +27915,7 @@ impl Pickle for MtaRoute { 0 => Pickle::unpickle(stream).map(MtaRoute::Mx), 1 => Pickle::unpickle(stream).map(MtaRoute::Relay), 2 => Pickle::unpickle(stream).map(MtaRoute::Local), + 3 => Pickle::unpickle(stream).map(MtaRoute::InbuxaRelay), _ => None, } } @@ -27936,6 +27945,13 @@ impl IntoValue for MtaRoute { .insert_unchecked(Property::Type, JmapValue::Str("Local".into())); obj } + MtaRoute::InbuxaRelay(obj) => { + let mut obj = obj.into_value(); + obj.as_object_mut() + .unwrap() + .insert_unchecked(Property::Type, JmapValue::Str("InbuxaRelay".into())); + obj + } } } } @@ -27951,12 +27967,14 @@ impl RegistryJsonPatch for MtaRoute { MtaRouteType::Mx => *self = MtaRoute::Mx(Default::default()), MtaRouteType::Relay => *self = MtaRoute::Relay(Default::default()), MtaRouteType::Local => *self = MtaRoute::Local(Default::default()), + MtaRouteType::InbuxaRelay => *self = MtaRoute::InbuxaRelay(Default::default()), } } match self { MtaRoute::Mx(inner) => inner.patch(pointer, value), MtaRoute::Relay(inner) => inner.patch(pointer, value), MtaRoute::Local(inner) => inner.patch(pointer, value), + MtaRoute::InbuxaRelay(inner) => inner.patch(pointer, value), } } } @@ -27967,6 +27985,7 @@ impl MtaRoute { MtaRoute::Mx(_) => MtaRouteType::Mx, MtaRoute::Relay(_) => MtaRouteType::Relay, MtaRoute::Local(_) => MtaRouteType::Local, + MtaRoute::InbuxaRelay(_) => MtaRouteType::InbuxaRelay, } } } @@ -28041,6 +28060,90 @@ impl RegistryJsonPropertyPatch for MtaRouteCommon { } } +// inbuxa: relay spec, RL-24 +impl MtaRouteInbuxaRelay { + fn validate(&self, errors: &mut Vec) -> bool { + let neb = errors.len(); + let value = &self.relays; + for value in value.iter() { + if value.is_empty() { + errors.push(ValidationError::required(Property::Relays)); + } + } + let value = &self.name; + if value.is_empty() { + errors.push(ValidationError::required(Property::Name)); + } + if let Some(value) = &self.description { + if value.is_empty() { + errors.push(ValidationError::required(Property::Description)); + } + } + errors.len() == neb + } + + fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { + i.unique(Property::Name, &self.name); + } +} + +impl Pickle for MtaRouteInbuxaRelay { + fn pickle(&self, out: &mut Vec) { + self.relays.pickle(out); + self.name.pickle(out); + self.description.pickle(out); + } + + fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { + let mut this = Self::default(); + this.relays = Pickle::unpickle(stream)?; + this.name = Pickle::unpickle(stream)?; + this.description = Pickle::unpickle(stream)?; + Some(this) + } +} + +impl Default for MtaRouteInbuxaRelay { + fn default() -> Self { + Self { + relays: Default::default(), + name: Default::default(), + description: Default::default(), + } + } +} + +impl IntoValue for MtaRouteInbuxaRelay { + fn into_value(self) -> JmapValue<'static> { + let mut map = jmap_tools::Map::with_capacity(4); + map.insert_unchecked(Property::Relays, self.relays.into_value()); + map.insert_unchecked(Property::Name, self.name.into_value()); + map.insert_unchecked(Property::Description, self.description.into_value()); + JmapValue::Object(map) + } +} + +impl RegistryJsonPropertyPatch for MtaRouteInbuxaRelay { + fn patch_property<'x>( + &mut self, + mut pointer: JsonPointerPatch<'_>, + value: JmapValue<'x>, + ) -> PatchResult<'x> { + match pointer.next_property() { + Some(Property::Relays) => self + .relays + .patch(pointer.with_validators(&[StringValidator::Trim]), value), + Some(Property::Name) => self.name.patch(pointer.assert_read_only()?, value), + Some(Property::Description) => self.description.patch(pointer, value), + Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { + property: Property::Type, + value, + }), + _ => Err(PatchError::new(pointer, "Invalid property")), + } + } +} + impl MtaRouteMx { fn validate(&self, errors: &mut Vec) -> bool { let neb = errors.len(); diff --git a/crates/smtp/src/outbound/delivery.rs b/crates/smtp/src/outbound/delivery.rs index 8eaa0f29..4a158369 100644 --- a/crates/smtp/src/outbound/delivery.rs +++ b/crates/smtp/src/outbound/delivery.rs @@ -239,8 +239,9 @@ impl QueuedMessage { } } // inbuxa: with a relay's sendThrough on, every non-local recipient - // goes through a relay (relay spec, RL-24) - static THROUGH_RELAY: RoutingStrategy = RoutingStrategy::InbuxaRelay; + // goes through a relay, any of them unless its route names some + // (relay spec, RL-24) + static THROUGH_RELAY: RoutingStrategy = RoutingStrategy::InbuxaRelay(Vec::new()); let senders = &server.inner.data.relay_senders; if let Err(err) = senders.load(server.store()).await { trc::error!(err.details("Failed to read the relays").span_id(span_id)); @@ -267,7 +268,11 @@ impl QueuedMessage { .unwrap_or_else(|| "default".to_string()), message.span_id, ); - let route = if through_relay && !matches!(route, RoutingStrategy::Local) { + let route = if through_relay + && !matches!( + route, + RoutingStrategy::Local | RoutingStrategy::InbuxaRelay(_) + ) { &THROUGH_RELAY } else { route @@ -334,9 +339,15 @@ impl QueuedMessage { continue 'next_route; } // inbuxa: handed to an inbuxa Relay (relay spec, RL-24) - RoutingStrategy::InbuxaRelay => { + RoutingStrategy::InbuxaRelay(named) => { message - .hand_to_relay(&rcpt_idxs, rcpt_headers, &mut delivery_results, &server) + .hand_to_relay( + named, + &rcpt_idxs, + rcpt_headers, + &mut delivery_results, + &server, + ) .await; continue 'next_route; } diff --git a/crates/smtp/src/outbound/relay.rs b/crates/smtp/src/outbound/relay.rs index ef41db7b..054d1e6c 100644 --- a/crates/smtp/src/outbound/relay.rs +++ b/crates/smtp/src/outbound/relay.rs @@ -8,7 +8,8 @@ //! RL-20, RL-24). //! //! With a relay's `sendThrough` on, every non-local recipient is handed to -//! one of the relays this node is linked to, in turn. The recipients stay +//! one of the relays this node is linked to, in turn; a recipient whose +//! route is an `InbuxaRelay` route goes to one of the relays it names. The recipients stay //! in this server's queue, marked [`RCPT_RELAYED`] and shown as handed to //! the relay, until the link service reports the relay's final status for //! them through [`apply_relayed`]. The usual DSNs follow from there. With @@ -47,19 +48,29 @@ use types::id::Id; pub const RELAYED_RECHECK: u64 = 3_600; impl MessageWrapper { - /// Hands one domain's recipients to the first relay that takes them. + /// Hands one domain's recipients to the first relay that takes them, + /// of those `named`, or of all when none are. pub(super) async fn hand_to_relay( &self, + named: &[String], rcpt_idxs: &[usize], rcpt_headers: Option<&[u8]>, results: &mut Vec, server: &Server, ) { - let candidates = server.inner.data.relay_senders.candidates(); + let candidates = server.inner.data.relay_senders.candidates(named); if candidates.is_empty() { + let reason = if named.is_empty() { + "No relay is linked from this node.".to_string() + } else { + format!( + "No relay named {} is linked from this node and sending.", + named.join(" or ") + ) + }; let status = Status::TemporaryFailure(ErrorDetails { entity: "relay".into(), - details: Error::ConnectionError("No relay is linked from this node.".into()), + details: Error::ConnectionError(reason.into()), }); results.push(DeliveryResult::domain(status, rcpt_idxs.to_vec())); return; diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index dbe5962a..1f208771 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -552,9 +552,15 @@ allowing the edge's IP and disabling SPF, DMARC and reverse IP for it. ### Outbound routing -- **RL-24.** A new route type, `inbuxaRelay`, alongside the existing route - types, names one or more `inbuxa:Relay`s. With `sendThrough` on, it is the - route for every non-local recipient. +- **RL-24.** A new route type, `InbuxaRelay`, alongside the existing route + types, names one or more `inbuxa:Relay`s in `relays`, by name (every + relay with that name), or none, for any relay. The queue's `route` + expression picks it per recipient like any other route, so some domains + can go through relays while the rest go direct. Its recipients go through + the relays named, chosen as below; a relay renamed stops matching. With + `sendThrough` on, every non-local recipient is routed through relays, to + the relays its route names if it is an `InbuxaRelay` route, to any + otherwise. - `sendThrough` on any enabled relay sends every non-local recipient through relays: the enabled ones not marked `inboundOnly`, that the node delivering is linked to, each message starting with the next in turn. @@ -841,7 +847,11 @@ In progress. Done so far: `outbound.tls` into a session in this server's TLS-RPT reports, with the policy, result, MX host, address and reason (tested with a relay's STARTTLS failure for REQUIRETLS mail). -- Still to come in step 4: the named `inbuxaRelay` route. +- The `InbuxaRelay` route type (`x:MtaRoute`, stored as its fourth + variant, so routes saved before read as they were), in the served schema + for the console's route form. Tested end to end with `sendThrough` off: + a domain routed to a route naming the relay goes through it, and one + routed to a route naming no linked relay waits, saying so. - `crates/outbound-tls` (RL-30): the TLSA and MTA-STS types, the DANE certificate check, MTA-STS policy parsing and matching, and the DNSSEC lookups (with the bogus-answer recheck), moved out of `crates/smtp` and @@ -859,7 +869,7 @@ Order: `inbuxa:Relay`, ingest with session context (RL-23). 3. Live mode (RL-12, RL-13). Steps 2 and 3 ship together: held mode alone can't refuse spam at the door, which the settings-only edge already can. -4. Outbound: `Outbound`, the `inbuxaRelay` route, status and DSNs, TLS-RPT +4. Outbound: `Outbound`, the `InbuxaRelay` route, status and DSNs, TLS-RPT events. 5. inbuxa-admin's Relays page, certificates (RL-27), DNS (RL-25), and upgrades (RL-29). diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 36946ce9..d48a3035 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -1595,6 +1595,9 @@ default = "none" [object."x:MtaRouteCommon"] default = "none" +[object."x:MtaRouteInbuxaRelay"] +default = "none" + [object."x:MtaRouteMx"] default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 4fd5e892..e4bd7ee6 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index ab036e1b..b33f0f1c 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -D6Ad4OELCzUv1LCDwL_v0XRNhdN-2jaO4HuYVRXzxu4 \ No newline at end of file +0ySrEn9k_JryaFkuzjsJUtJK_uK9OJvr-n11zkpboUo \ No newline at end of file diff --git a/tests/src/system/relay.rs b/tests/src/system/relay.rs index 2336719e..6cf5809b 100644 --- a/tests/src/system/relay.rs +++ b/tests/src/system/relay.rs @@ -26,12 +26,16 @@ use crate::utils::{ }; use inbuxa_relay_proto::{time, token::JoinToken}; use mail_auth::{common::parse::TxtRecordParser, mta_sts::TlsRpt}; -use registry::schema::{ - prelude::{ObjectType, Property}, - structs::{ - CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, TlsInternalReport, - UserRoles, +use registry::{ + schema::{ + prelude::{ObjectType, Property}, + structs::{ + CertificateManagement, DkimManagement, DnsManagement, Domain, Expression, + ExpressionMatch, MtaOutboundStrategy, MtaRoute, MtaRouteInbuxaRelay, Tenant, + TlsInternalReport, UserRoles, + }, }, + types::map::Map, }; use serde_json::{Value, json}; use std::str::FromStr; @@ -1021,6 +1025,108 @@ pub async fn link(test: &mut TestServer) { ); tokio::time::sleep(std::time::Duration::from_millis(500)).await; } + + // A route naming relays (RL-24): with sendThrough off, only the + // domains routed to it go through a relay, and only the relays named. + // One named relay that isn't there: the mail waits, saying so + call( + admin, + "inbuxa:Relay/set", + json!({"update": {&id: {"sendThrough": false}}}), + ) + .await; + for (name, relay) in [("through-edge", "Edge"), ("through-nowhere", "Nowhere")] { + admin + .registry_create_object(MtaRoute::InbuxaRelay(MtaRouteInbuxaRelay { + relays: Map::new(vec![relay.to_string()]), + name: name.to_string(), + description: None, + })) + .await; + } + admin + .registry_update_setting( + MtaOutboundStrategy { + route: Expression { + match_: [ + ExpressionMatch { + if_: "is_local_domain(rcpt_domain)".into(), + then: "'local'".into(), + }, + ExpressionMatch { + if_: "rcpt_domain == 'named.test'".into(), + then: "'through-edge'".into(), + }, + ExpressionMatch { + if_: "rcpt_domain == 'elsewhere.test'".into(), + then: "'through-nowhere'".into(), + }, + ] + .into_iter() + .collect(), + else_: "'mx'".into(), + }, + ..Default::default() + }, + &[Property::Route], + ) + .await; + admin.reload_settings().await; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while test.server.inner.data.relay_senders.sending() { + assert!( + std::time::Instant::now() < deadline, + "sendThrough off reached the queue" + ); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + submit( + &jane, + &identity, + &drafts, + &["bob@named.test"], + "Through the named relay", + ) + .await; + submit( + &jane, + &identity, + &drafts, + &["bob@elsewhere.test"], + "Through no relay", + ) + .await; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let (_, queued) = call(admin, "x:QueuedMessage/get", json!({"ids": null})).await; + let text = queued.to_string(); + let sent = mx_seen + .lock() + .unwrap() + .iter() + .any(|m| m.contains("Subject: Through the named relay")); + if sent + && !text.contains("bob@named.test") + && text.contains("bob@elsewhere.test") + && text.contains("No relay named Nowhere") + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "sent through Edge: {sent}; queued: {queued}" + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + assert!( + !mx_seen + .lock() + .unwrap() + .iter() + .any(|m| m.contains("Subject: Through no relay")), + "sent through a relay the route doesn't name" + ); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; assert_eq!( subjects(&jane)