api: Add list_transports_ex() and set_transport_unpublished() functions

Closes https://github.com/chatmail/core/issues/7980.

Unpublished transports are not advertised to contacts, and self-sent messages are not sent there, so that we don't cause extra messages to the corresponding inbox, but can still receive messages from contacts who don't know the new relay addresses yet.

- This adds `list_transports_ex()` and `set_transport_unpublished()` JsonRPC functions
- By default, transports are published, but when updating, all existing transports except for the primary one become unpublished in order not to break existing users that followed https://delta.chat/legacy-move
- It is not possible to unpublish the primary transport, and setting a transport as primary automatically sets it to published

An alternative would be to change the existing list_transports API rather than adding a new one list_transports_ex. But to be honest, I don't mind the _ex prefix that much, and I am wary about compatibility issues. But maybe it would be fine; see b08ba4bb8 for how this would look.
This commit is contained in:
Hocuri
2026-03-15 17:36:01 +01:00
parent c0cc2ae816
commit 810dab12dc
15 changed files with 453 additions and 61 deletions

View File

@@ -1,7 +1,13 @@
use std::collections::BTreeSet;
use std::time::Duration;
use crate::tools::SystemTime;
use super::*;
use crate::log::LogExt as _;
use crate::provider::get_provider_by_id;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools::time;
#[test]
@@ -239,34 +245,9 @@ async fn test_empty_server_list() -> Result<()> {
let addr = format!("alice@{domain}");
ConfiguredLoginParam {
addr: addr.clone(),
imap: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.clone(),
}],
imap_user: addr.clone(),
imap_password: "foobarbaz".to_string(),
smtp: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.clone(),
}],
smtp_user: addr.clone(),
smtp_password: "foobarbaz".to_string(),
provider: Some(provider),
certificate_checks: ConfiguredCertificateChecks::Automatic,
oauth2: false,
}
.save_to_transports_table(&t, &EnteredLoginParam::default(), time())
.await?;
dummy_configured_login_param(&addr, Some(provider))
.save_to_transports_table(&t, &EnteredLoginParam::default(), time())
.await?;
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(loaded.provider, Some(*provider));
@@ -276,3 +257,229 @@ async fn test_empty_server_list() -> Result<()> {
Ok(())
}
fn dummy_configured_login_param(
addr: &str,
provider: Option<&'static Provider>,
) -> ConfiguredLoginParam {
ConfiguredLoginParam {
addr: addr.to_string(),
imap: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.to_string(),
}],
imap_user: addr.to_string(),
imap_password: "foobarbaz".to_string(),
smtp: vec![ConfiguredServerLoginParam {
connection: ConnectionCandidate {
host: "example.org".to_string(),
port: 100,
security: ConnectionSecurity::Tls,
},
user: addr.to_string(),
}],
smtp_user: addr.to_string(),
smtp_password: "foobarbaz".to_string(),
provider,
certificate_checks: ConfiguredCertificateChecks::Automatic,
oauth2: false,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_published_flag() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
for a in [alice, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
a.set_config_bool(Config::BccSelf, true).await?;
}
let bob = &tcm.bob().await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@example.org",
secondary_published: &[],
secondary_unpublished: &[],
},
)
.await;
dummy_configured_login_param("alice@otherprovider.com", None)
.save_to_transports_table(
alice,
&EnteredLoginParam {
addr: "alice@otherprovider.com".to_string(),
..Default::default()
},
time(),
)
.await?;
send_sync_transports(alice).await?;
sync_and_check_recipients(alice, alice2, "alice@otherprovider.com alice@example.org").await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@example.org",
secondary_published: &["alice@otherprovider.com"],
secondary_unpublished: &[],
},
)
.await;
assert_eq!(
alice
.set_transport_unpublished("alice@example.org", true)
.await
.unwrap_err()
.to_string(),
"Can't set primary relay as unpublished"
);
// Make sure that the newly generated key has a newer timestamp,
// so that it is recognized by Bob:
SystemTime::shift(Duration::from_secs(2));
alice
.set_transport_unpublished("alice@otherprovider.com", true)
.await?;
sync_and_check_recipients(alice, alice2, "alice@example.org").await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@example.org",
secondary_published: &[],
secondary_unpublished: &["alice@otherprovider.com"],
},
)
.await;
SystemTime::shift(Duration::from_secs(2));
alice
.set_config(Config::ConfiguredAddr, Some("alice@otherprovider.com"))
.await?;
sync_and_check_recipients(alice, alice2, "alice@example.org alice@otherprovider.com").await;
check_addrs(
alice,
alice2,
bob,
Addresses {
primary: "alice@otherprovider.com",
secondary_published: &["alice@example.org"],
secondary_unpublished: &[],
},
)
.await;
Ok(())
}
struct Addresses {
primary: &'static str,
secondary_published: &'static [&'static str],
secondary_unpublished: &'static [&'static str],
}
async fn check_addrs(
alice: &TestContext,
alice2: &TestContext,
bob: &TestContext,
addresses: Addresses,
) {
fn assert_eq(left: Vec<String>, right: Vec<&'static str>) {
assert_eq!(
left.iter().map(|s| s.as_str()).collect::<BTreeSet<_>>(),
right.into_iter().collect::<BTreeSet<_>>(),
)
}
let published_self_addrs = concat(&[addresses.secondary_published, &[addresses.primary]]);
for a in [alice2, alice] {
assert_eq(
a.get_all_self_addrs().await.unwrap(),
concat(&[
addresses.secondary_published,
addresses.secondary_unpublished,
&[addresses.primary],
]),
);
assert_eq(
a.get_published_self_addrs().await.unwrap(),
published_self_addrs.clone(),
);
assert_eq(
a.get_secondary_self_addrs().await.unwrap(),
concat(&[
addresses.secondary_published,
addresses.secondary_unpublished,
]),
);
assert_eq(
a.get_published_secondary_self_addrs().await.unwrap(),
concat(&[addresses.secondary_published]),
);
for transport in a.list_transports().await.unwrap() {
if addresses.primary == transport.param.addr
|| addresses
.secondary_published
.contains(&transport.param.addr.as_str())
{
assert_eq!(transport.is_unpublished, false);
} else if addresses
.secondary_unpublished
.contains(&transport.param.addr.as_str())
{
assert_eq!(transport.is_unpublished, true);
} else {
panic!("Unexpected transport {transport:?}");
}
}
let alice_bob_chat_id = a.create_chat_id(bob).await;
let sent = a.send_text(alice_bob_chat_id, "hi").await;
assert_eq!(
sent.recipients,
format!("bob@example.net {}", published_self_addrs.join(" ")),
"{} is sending to the wrong set of recipients",
a.name()
);
let bob_alice_chat_id = bob.recv_msg(&sent).await.chat_id;
bob_alice_chat_id.accept(bob).await.unwrap();
let answer = bob.send_text(bob_alice_chat_id, "hi back").await;
assert_eq(
answer.recipients.split(' ').map(Into::into).collect(),
concat(&[&published_self_addrs, &["bob@example.net"]]),
);
}
}
fn concat(slices: &[&[&'static str]]) -> Vec<&'static str> {
let mut res = vec![];
for s in slices {
res.extend(*s);
}
res
}
pub async fn sync_and_check_recipients(from: &TestContext, to: &TestContext, recipients: &str) {
from.send_sync_msg().await.unwrap();
let sync_msg = from.pop_sent_msg().await;
assert_eq!(sync_msg.recipients, recipients);
to.recv_msg_trash(&sync_msg).await;
}