Relay: the InbuxaRelay route type, naming the relays to send through #264

Merged
jcoffey-dev merged 1 commits from relay/route into main 2026-10-30 19:59:19 +00:00
16 changed files with 389 additions and 38 deletions

No files matched your search

+10 -2
View File
@@ -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<String>),
}
#[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()),
);
}
}
}
+47 -12
View File
@@ -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<u32>,
/// 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<u32, String>,
linked: BTreeMap<u32, Arc<Linked>>,
}
@@ -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<Arc<Linked>> {
/// 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<Arc<Linked>> {
let state = self.state.lock().expect("senders");
let mut linked: Vec<Arc<Linked>> = 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<u32> = senders.candidates().iter().map(|l| l.relay_id).collect();
let ids: Vec<u32> = 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<u32> {
let named: Vec<String> = named.iter().map(|n| n.to_string()).collect();
let mut ids: Vec<u32> = 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]);
}
}
+42 -2
View File
@@ -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::<String>.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));
}
+2
View File
@@ -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)]
+4 -1
View File
@@ -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 {
+2
View File
@@ -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,
@@ -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 {
+14
View File
@@ -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<String>,
}
// 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<String>,
#[serde(rename = "name")]
pub name: String,
#[serde(rename = "description")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MtaRouteMx {
+103
View File
@@ -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<ValidationError>) -> 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<u8>) {
self.relays.pickle(out);
self.name.pickle(out);
self.description.pickle(out);
}
fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option<Self> {
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<ValidationError>) -> bool {
let neb = errors.len();
+16 -5
View File
@@ -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;
}
+15 -4
View File
@@ -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<DeliveryResult>,
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;
+15 -5
View File
@@ -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).
+3
View File
@@ -1595,6 +1595,9 @@ default = "none"
[object."x:MtaRouteCommon"]
default = "none"
[object."x:MtaRouteInbuxaRelay"]
default = "none"
[object."x:MtaRouteMx"]
default = "none"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
D6Ad4OELCzUv1LCDwL_v0XRNhdN-2jaO4HuYVRXzxu4
0ySrEn9k_JryaFkuzjsJUtJK_uK9OJvr-n11zkpboUo
+111 -5
View File
@@ -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,
&["[email protected]"],
"Through the named relay",
)
.await;
submit(
&jane,
&identity,
&drafts,
&["[email protected]"],
"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("[email protected]")
&& text.contains("[email protected]")
&& 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)