//! # Email accounts autoconfiguration process. //! //! The module provides automatic lookup of configuration for email providers //! using [Mozilla Thunderbird Autoconfiguration protocol] //! and [Outlook's Autodiscover]. //! //! [Mozilla Thunderbird Autoconfiguration protocol]: auto_mozilla //! [Outlook's Autodiscover]: auto_outlook mod auto_mozilla; mod auto_outlook; pub(crate) mod server_params; use anyhow::{Context as _, Result, bail, ensure, format_err}; use auto_mozilla::moz_autoconfigure; use auto_outlook::outlk_autodiscover; use deltachat_contact_tools::{EmailAddress, addr_normalize}; use futures::FutureExt; use futures_lite::FutureExt as _; use percent_encoding::utf8_percent_encode; use server_params::{ServerParams, expand_param_vector}; use tokio::task; use crate::config::Config; use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT; use crate::context::Context; use crate::imap::Imap; use crate::log::warn; pub use crate::login_param::EnteredLoginParam; use crate::login_param::{EnteredCertificateChecks, TransportListEntry}; use crate::net::proxy::ProxyConfig; use crate::provider::{self, Protocol, Socket}; use crate::qr::{login_param_from_account_qr, login_param_from_login_qr}; use crate::smtp::Smtp; use crate::sync::Sync::Nosync; use crate::tools::time; use crate::transport::{ ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam, ConnectionCandidate, send_sync_transports, }; use crate::{EventType, stock_str}; /// Maximum number of relays. /// /// See . pub(crate) const MAX_RELAYS: usize = 5; macro_rules! progress { ($context:tt, $progress:expr, $comment:expr) => { assert!( $progress <= 1000, "value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success" ); $context.emit_event($crate::events::EventType::ConfigureProgress { progress: $progress, comment: $comment, }); }; ($context:tt, $progress:expr) => { progress!($context, $progress, None); }; } impl Context { /// Checks if the context is already configured. pub async fn is_configured(&self) -> Result { self.sql.exists("SELECT COUNT(*) FROM transports", ()).await } /// Configures this account with the currently provided parameters. /// /// Deprecated since 2025-02; use `add_transport_from_qr()` /// or `add_or_update_transport()` instead. pub async fn configure(&self) -> Result<()> { let mut param = EnteredLoginParam::load_legacy(self).await?; self.add_transport_inner(&mut param).await } /// Configures a new email account using the provided parameters /// and adds it as a transport. /// /// If the email address is the same as an existing transport, /// then this existing account will be reconfigured instead of a new one being added. /// /// This function stops and starts IO as needed. /// /// Usually it will be enough to only set `addr` and `imap.password`, /// and all the other settings will be autoconfigured. /// /// During configuration, ConfigureProgress events are emitted; /// they indicate a successful configuration as well as errors /// and may be used to create a progress bar. /// This function will return after configuration is finished. /// /// If configuration is successful, /// the working server parameters will be saved /// and used for connecting to the server. /// The parameters entered by the user will be saved separately /// so that they can be prefilled when the user opens the server-configuration screen again. /// /// See also: /// - [Self::is_configured()] to check whether there is /// at least one working transport. /// - [Self::add_transport_from_qr()] to add a transport /// from a server encoded in a QR code. /// - [Self::list_transports()] to get a list of all configured transports. /// - [Self::delete_transport()] to remove a transport. /// - [Self::set_transport_unpublished()] to set whether contacts see this transport. pub async fn add_or_update_transport(&self, param: &mut EnteredLoginParam) -> Result<()> { self.stop_io().await; let result = self.add_transport_inner(param).await; if result.is_err() { if let Ok(true) = self.is_configured().await { self.start_io().await; } return result; } self.start_io().await; Ok(()) } pub(crate) async fn add_transport_inner(&self, param: &mut EnteredLoginParam) -> Result<()> { match self.add_transport_unreported(param).await { Ok(()) => { progress!(self, 1000); Ok(()) } Err(err) => { // We are using Anyhow's .context() and to show the // inner error, too, we need the {:#}: let error_msg = stock_str::configuration_failed(self, &format!("{err:#}")); progress!(self, 0, Some(error_msg.clone())); bail!(error_msg); } } } /// Adds a transport without reporting the outcome. async fn add_transport_unreported(&self, param: &mut EnteredLoginParam) -> Result<()> { ensure!( !self.scheduler.is_running().await, "cannot configure, already running" ); ensure!( self.sql.is_open().await, "cannot configure, database not opened." ); param.addr = addr_normalize(¶m.addr); let cancel_channel = self.alloc_ongoing().await?; let res = self .inner_configure(param) .race(cancel_channel.recv().map(|_| Err(format_err!("Canceled")))) .await; self.free_ongoing().await; res?; param.save_legacy(self).await } /// Adds a new email account as a transport /// using the server encoded in the QR code. /// See [Self::add_or_update_transport]. pub async fn add_transport_from_qr(&self, qr: &str) -> Result<()> { self.stop_io().await; let result = async move { let mut param = match crate::qr::check_qr(self, qr).await? { crate::qr::Qr::Account { .. } => login_param_from_account_qr(self, qr).await?, crate::qr::Qr::Login { address, options } => { login_param_from_login_qr(&address, options)? } _ => bail!("QR code does not contain account"), }; self.add_transport_inner(&mut param).await?; Ok(()) } .await; if result.is_err() { if let Ok(true) = self.is_configured().await { self.start_io().await; } return result; } self.start_io().await; Ok(()) } /// Returns the list of all email accounts that are used as a transport in the current profile. /// Use [Self::add_or_update_transport()] to add or change a transport /// and [Self::delete_transport()] to delete a transport. pub async fn list_transports(&self) -> Result> { let transports = self .sql .query_map_vec( "SELECT entered_param, is_published FROM transports", (), |row| { let param: String = row.get(0)?; let param: EnteredLoginParam = serde_json::from_str(¶m)?; let is_published: bool = row.get(1)?; Ok(TransportListEntry { param, is_unpublished: !is_published, }) }, ) .await?; Ok(transports) } /// Returns the number of configured transports. pub async fn count_transports(&self) -> Result { self.sql.count("SELECT COUNT(*) FROM transports", ()).await } /// Immediately deletes a transport, potentially causing messages not to arrive. /// This must ONLY be used internally and by the automated tests. /// UI implementations must use [`Self::set_transport_unpublished`] instead. pub async fn delete_transport(&self, addr: &str) -> Result<()> { let now = time(); let removed_transport_id = self .sql .transaction(|transaction| { let primary_addr = transaction.query_row( "SELECT value FROM config WHERE keyname='configured_addr'", (), |row| { let addr: String = row.get(0)?; Ok(addr) }, )?; if primary_addr == addr { bail!("Cannot delete primary transport"); } let (transport_id, add_timestamp) = transaction.query_row( "DELETE FROM transports WHERE addr=? RETURNING id, add_timestamp", (addr,), |row| { let id: u32 = row.get(0)?; let add_timestamp: i64 = row.get(1)?; Ok((id, add_timestamp)) }, )?; // Removal timestamp should not be lower than addition timestamp // to be accepted by other devices when synced. let remove_timestamp = std::cmp::max(now, add_timestamp); transaction.execute( "INSERT INTO removed_transports (addr, remove_timestamp) VALUES (?, ?) ON CONFLICT (addr) DO UPDATE SET remove_timestamp = excluded.remove_timestamp", (addr, remove_timestamp), )?; Ok(transport_id) }) .await?; send_sync_transports(self).await?; self.quota.write().await.remove(&removed_transport_id); self.restart_io_if_running().await; Ok(()) } /// Change whether the transport is unpublished. /// UIs should call this function when the user clicks on "Remove". /// Core will keep listening on this transport for some time, /// and automatically remove it once it is no longer needed. /// /// 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 our new transport addresses yet. /// /// When more transports are added by [`Self::add_or_update_transport()`] or [`Self::add_transport_from_qr`], /// the least recently needed unpublished transport is automatically removed /// if this is necessary in order to stay below the maximum number of allowed relays. /// Also, unpublished transports that are not used to receive any new messages for a time defined by /// `UNPUBLISHED_TRANSPORT_KEEP_TIME` are automatically removed. pub async fn set_transport_unpublished(&self, addr: &str, unpublished: bool) -> Result<()> { self.sql .transaction(|trans| { let primary_addr: String = trans .query_row( "SELECT value FROM config WHERE keyname='configured_addr'", (), |row| row.get(0), ) .context("Select primary address")?; if primary_addr == addr && unpublished { bail!("Can't set primary relay as unpublished"); } // We need to update the timestamp so that the key's timestamp changes // and is recognized as newer by our peers trans .execute( "UPDATE transports SET is_published=?, add_timestamp=? WHERE addr=? AND is_published!=?1", (!unpublished, time(), addr), ) .context("Update transports")?; Ok(()) }) .await?; send_sync_transports(self).await?; Ok(()) } async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> { info!(self, "Configure ..."); if !self .sql .exists( "SELECT COUNT(*) FROM transports WHERE addr=?", (¶m.addr,), ) .await? { self.try_make_space_for_new_relay().await?; } let skip_network = false; if let Err(error) = configure(self, param, skip_network).await { // Log entered and actual params let configured_param = get_configured_param(self, param, skip_network).await; warn!( self, "configure failed: Entered params: {}. Used params: {}. Error: {error}.", param.to_string(), configured_param .map(|param| param.to_string()) .unwrap_or("error".to_owned()) ); return Err(error); }; if provider::legacy_settings_for_addr(¶m.addr)?.worse_media_quality && !self.config_exists(Config::MediaQuality).await? { self.set_config_ext(Nosync, Config::MediaQuality, Some("1")) .await?; } Ok(()) } /// This function is called before adding a new relay. /// If the maximum number of relays ([`MAX_RELAYS`]) is already reached, /// then it tries to make space by removing an unpublished relay. /// If there are multiple unpublished relays, /// the one that hasn't received a message for longest is removed. /// If there are no unpublished relays, an error is returned. /// /// Note that eviction happens before we know that a new relay works, /// which is a trade-off we made in favor of implementation complexity. async fn try_make_space_for_new_relay(&self) -> Result<()> { if self.count_transports().await? >= MAX_RELAYS { // Try to automatically remove the unpublished transport that wasn't used for the longest time: if let Some(addr) = self .sql .query_get_value::( "SELECT addr FROM transports WHERE is_published=0 ORDER BY last_rcvd_timestamp, add_timestamp LIMIT 1", (), ) .await? { info!( self, "Auto-deleting relay {addr} to make space for new relay." ); self.delete_transport(&addr).await?; } if self.count_transports().await? >= MAX_RELAYS { // Apparently, all the transports are published bail!("You have reached the maximum number of relays ({MAX_RELAYS})"); } }; Ok(()) } } /// Retrieves data from autoconfig /// to transform user-entered login parameters into complete configuration. async fn get_configured_param( ctx: &Context, param: &EnteredLoginParam, skip_network: bool, ) -> Result { ensure!(!param.addr.is_empty(), "Missing email address."); ensure!(!param.imap.password.is_empty(), "Missing (IMAP) password."); // SMTP password is an "advanced" setting. If unset, use the same password as for IMAP. let smtp_password = if param.smtp.password.is_empty() { param.imap.password.clone() } else { param.smtp.password.clone() }; let addr = param.addr.clone(); let parsed = EmailAddress::new(¶m.addr).context("Bad email-address")?; let param_domain = parsed.domain; progress!(ctx, 200); let param_autoconfig = if param.imap.server.is_empty() && param.imap.port == 0 && param.imap.security == Socket::Automatic && param.imap.user.is_empty() && param.smtp.server.is_empty() && param.smtp.port == 0 && param.smtp.security == Socket::Automatic && param.smtp.user.is_empty() && !skip_network { // No advanced parameters entered by the user: // do Autoconfig unless the domain has hard-coded legacy servers. match provider::legacy_settings_for_addr(¶m.addr)?.autoconfig_servers { Some(servers) => Some(servers), None => get_autoconfig(ctx, param, ¶m_domain).await, } } else { None }; progress!(ctx, 500); let mut servers = param_autoconfig.unwrap_or_default(); if !servers .iter() .any(|server| server.protocol == Protocol::Imap) { servers.push(ServerParams { protocol: Protocol::Imap, hostname: param.imap.server.clone(), port: param.imap.port, socket: param.imap.security, username: param.imap.user.clone(), }) } if !servers .iter() .any(|server| server.protocol == Protocol::Smtp) { servers.push(ServerParams { protocol: Protocol::Smtp, hostname: param.smtp.server.clone(), port: param.smtp.port, socket: param.smtp.security, username: param.smtp.user.clone(), }) } let servers = expand_param_vector(servers, ¶m.addr, ¶m_domain); let configured_login_param = ConfiguredLoginParam { addr, imap: servers .iter() .filter_map(|params| { let Ok(security) = params.socket.try_into() else { return None; }; if params.protocol == Protocol::Imap { Some(ConfiguredServerLoginParam { connection: ConnectionCandidate { host: params.hostname.clone(), port: params.port, security, }, user: params.username.clone(), }) } else { None } }) .collect(), imap_user: param.imap.user.clone(), imap_password: param.imap.password.clone(), imap_folder: Some(param.imap.folder.clone()).filter(|folder| !folder.is_empty()), smtp: servers .iter() .filter_map(|params| { let Ok(security) = params.socket.try_into() else { return None; }; if params.protocol == Protocol::Smtp { Some(ConfiguredServerLoginParam { connection: ConnectionCandidate { host: params.hostname.clone(), port: params.port, security, }, user: params.username.clone(), }) } else { None } }) .collect(), smtp_user: param.smtp.user.clone(), smtp_password, certificate_checks: match param.certificate_checks { EnteredCertificateChecks::Automatic => ConfiguredCertificateChecks::Automatic, EnteredCertificateChecks::Strict => ConfiguredCertificateChecks::Strict, EnteredCertificateChecks::AcceptInvalidCertificates | EnteredCertificateChecks::AcceptInvalidCertificates2 => { ConfiguredCertificateChecks::AcceptInvalidCertificates } }, }; Ok(configured_login_param) } pub(crate) async fn configure( ctx: &Context, param: &EnteredLoginParam, skip_network: bool, ) -> Result<()> { progress!(ctx, 1); let configured_param = get_configured_param(ctx, param, skip_network).await?; let proxy_config = ProxyConfig::load(ctx).await?; let strict_tls = configured_param.strict_tls(proxy_config.is_some())?; progress!(ctx, 550); if !skip_network { // Spawn SMTP configuration task // to try SMTP while connecting to IMAP. let context_smtp = ctx.clone(); let smtp_param = configured_param.smtp.clone(); let smtp_password = configured_param.smtp_password.clone(); let smtp_addr = configured_param.addr.clone(); let proxy_config2 = proxy_config.clone(); let smtp_config_task = task::spawn(async move { let mut smtp = Smtp::new(); smtp.connect( &context_smtp, &smtp_param, &smtp_password, &proxy_config2, &smtp_addr, strict_tls, ) .await?; Ok::<(), anyhow::Error>(()) }); progress!(ctx, 600); // Configure IMAP let transport_id = 0; let (_s, r) = async_channel::bounded(1); let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?; let imap_session = match imap.connect(ctx).await { Ok(imap_session) => imap_session, Err(err) => { bail!("{}", nicer_configuration_error(ctx, format!("{err:#}"))); } }; progress!(ctx, 850); // Wait for SMTP configuration smtp_config_task.await??; progress!(ctx, 900); let is_configured = ctx.is_configured().await?; if !ctx.get_config_bool(Config::FixIsChatmail).await? { if imap_session.is_chatmail() { ctx.sql.set_raw_config("is_chatmail", Some("1")).await?; } else if !is_configured { // Reset the setting that may have been set // during failed configuration. ctx.sql.set_raw_config("is_chatmail", Some("0")).await?; } } // Drop the imap connection explicitly // to make sure that it's not forgotten in a future refactoring drop(imap_session); drop(imap); } progress!(ctx, 910); configured_param .clone() .save_to_transports_table(ctx, param, time()) .await?; send_sync_transports(ctx).await?; ctx.set_config_internal(Config::ConfiguredTimestamp, Some(&time().to_string())) .await?; progress!(ctx, 920); ctx.scheduler.interrupt_inbox().await; progress!(ctx, 940); ctx.update_device_chats() .await .context("Failed to update device chats")?; ctx.sql.set_raw_config_bool("configured", true).await?; ctx.emit_event(EventType::AccountsItemChanged); Ok(()) } /// Retrieve available autoconfigurations. /// /// A. Search configurations from the domain used in the email-address /// B. If we have no configuration yet, search configuration in Thunderbird's central database async fn get_autoconfig( ctx: &Context, param: &EnteredLoginParam, param_domain: &str, ) -> Option> { let accept_invalid_certificates = param.certificate_checks.accept_invalid_certificates(); // Make sure to not encode `.` as `%2E` here. // Some servers like murena.io on 2024-11-01 produce incorrect autoconfig XML // when address is encoded. // E.g. // // produced XML file with `foobar@example%2Eorg` // resulting in failure to log in. let param_addr_urlencoded = utf8_percent_encode(¶m.addr, NON_ALPHANUMERIC_WITHOUT_DOT).to_string(); if let Ok(res) = moz_autoconfigure( ctx, &format!( "https://autoconfig.{param_domain}/mail/config-v1.1.xml?emailaddress={param_addr_urlencoded}" ), ¶m.addr, accept_invalid_certificates, ) .await { return Some(res); } progress!(ctx, 300); // `?emailaddress=` query string is excluded on purpose. // It is not part of the URL according to . // Related discussion confirming this is at . if let Ok(res) = moz_autoconfigure( ctx, &format!("https://{param_domain}/.well-known/autoconfig/mail/config-v1.1.xml"), ¶m.addr, accept_invalid_certificates, ) .await { return Some(res); } progress!(ctx, 310); // Outlook uses always SSL but different domains (this comment describes the next two steps) if let Ok(res) = outlk_autodiscover( ctx, format!("https://{param_domain}/autodiscover/autodiscover.xml"), accept_invalid_certificates, ) .await { return Some(res); } progress!(ctx, 320); if let Ok(res) = outlk_autodiscover( ctx, format!("https://autodiscover.{param_domain}/autodiscover/autodiscover.xml",), accept_invalid_certificates, ) .await { return Some(res); } progress!(ctx, 330); // always SSL for Thunderbird's database if let Ok(res) = moz_autoconfigure( ctx, &format!("https://autoconfig.thunderbird.net/v1.1/{param_domain}"), ¶m.addr, accept_invalid_certificates, ) .await { return Some(res); } None } fn nicer_configuration_error(context: &Context, e: String) -> String { if e.to_lowercase().contains("could not resolve") || e.to_lowercase().contains("connection attempts") || e.to_lowercase() .contains("temporary failure in name resolution") || e.to_lowercase().contains("name or service not known") || e.to_lowercase() .contains("failed to lookup address information") { return stock_str::error_no_network(context); } e } #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Invalid email address: {0:?}")] InvalidEmailAddress(String), #[error("XML error at position {position}: {error}")] InvalidXml { position: u64, #[source] error: quick_xml::Error, }, #[error("Number of redirection is exceeded")] Redirection, #[error("{0:#}")] Other(#[from] anyhow::Error), } #[cfg(test)] mod tests { use crate::tools::SystemTime; use super::*; use crate::automatic_relay_management::login_param_from_host; use crate::config::Config; use crate::login_param::EnteredImapLoginParam; use crate::sql::update_transport_last_rcvd_timestamp; use crate::test_utils::{TestContext, TestContextManager}; use crate::transport::add_pseudo_transport; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_no_panic_on_bad_credentials() { let t = TestContext::new().await; t.set_config(Config::Addr, Some("probably@unexistant.addr")) .await .unwrap(); t.set_config(Config::MailPw, Some("123456")).await.unwrap(); assert!(t.configure().await.is_err()); t.assert_warns_or_errors(&["DNS resolution"]).await; } /// Tests that a configuration failing /// before the first login attempt is still reported as a failure. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_early_configure_failure_is_reported() -> Result<()> { let t = TestContext::new().await; let mut param = login_param_from_host("example.org"); // An ongoing process, e.g. a backup import, // makes configuration fail without ever contacting a relay. let _ongoing = t.alloc_ongoing().await?; assert!(t.add_or_update_transport(&mut param).await.is_err()); let event = t .evtracker .get_matching(|evt| matches!(evt, EventType::ConfigureProgress { .. })) .await; assert!(matches!( event, EventType::ConfigureProgress { progress: 0, .. } )); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_configured_param() -> Result<()> { let t = &TestContext::new().await; let entered_param = EnteredLoginParam { addr: "alice@example.org".to_string(), imap: EnteredImapLoginParam { user: "alice@example.net".to_string(), password: "foobar".to_string(), ..Default::default() }, ..Default::default() }; let skip_network = false; let configured_param = get_configured_param(t, &entered_param, skip_network).await?; assert_eq!(configured_param.imap_user, "alice@example.net"); assert_eq!(configured_param.smtp_user, ""); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_try_make_place_for_new_relay() -> Result<()> { let t = TestContext::new().await; // Setting ConfiguredAddr on an unconfigured account creates a pseudo primary transport t.set_config(Config::ConfiguredAddr, Some("primary@example.org")) .await?; // Test that try_make_place_for_new_relay() doesn't do anything when we're below the limit assert_eq!(t.count_transports().await?, 1); t.try_make_space_for_new_relay().await?; assert_eq!(t.count_transports().await?, 1); for i in 0..(MAX_RELAYS - 2) { add_pseudo_transport(&t, &format!("transport{i}@example.org")).await?; } assert_eq!(t.count_transports().await?, MAX_RELAYS - 1); t.try_make_space_for_new_relay().await?; assert_eq!(t.count_transports().await?, MAX_RELAYS - 1); // Test that try_make_place_for_new_relay() removes the unpublished transport // when we're at the limit add_pseudo_transport(&t, "unpublished@example.org").await?; t.set_transport_unpublished("unpublished@example.org", true) .await?; assert_eq!(t.count_transports().await?, MAX_RELAYS); t.try_make_space_for_new_relay().await?; assert_eq!(t.count_transports().await?, MAX_RELAYS - 1); assert_eq!( t.sql .exists( "SELECT COUNT(*) FROM transports WHERE addr=?", ("unpublished@example.org",), ) .await?, false ); // Test that if there are multiple unpublished relays, // the one that was used least recently is removed t.set_transport_unpublished("transport0@example.org", true) .await?; add_pseudo_transport(&t, "other_unpublished@example.org").await?; t.set_transport_unpublished("other_unpublished@example.org", true) .await?; assert_eq!(t.count_transports().await?, MAX_RELAYS); let transport0_id: u32 = t .sql .query_get_value( "SELECT id FROM transports WHERE addr=?", ("transport0@example.org",), ) .await? .unwrap(); let other_unpublished_id: u32 = t .sql .query_get_value( "SELECT id FROM transports WHERE addr=?", ("other_unpublished@example.org",), ) .await? .unwrap(); update_transport_last_rcvd_timestamp(&t, transport0_id).await?; SystemTime::shift(std::time::Duration::from_secs(10)); update_transport_last_rcvd_timestamp(&t, other_unpublished_id).await?; // Test that try_make_place_for_new_relay() // removes the relay with the oldest last_rcvd_timestamp t.try_make_space_for_new_relay().await?; assert_eq!(t.count_transports().await?, MAX_RELAYS - 1); assert_eq!( t.sql .exists( "SELECT COUNT(*) FROM transports WHERE addr=?", ("transport0@example.org",), ) .await?, false ); assert_eq!( t.sql .exists( "SELECT COUNT(*) FROM transports WHERE addr=?", ("other_unpublished@example.org",), ) .await?, true ); // Test that try_make_place_for_new_relay() fails // if there are MAX_RELAYS published transports add_pseudo_transport(&t, "published_extra@example.org").await?; t.set_transport_unpublished("other_unpublished@example.org", false) .await?; assert_eq!(t.count_transports().await?, MAX_RELAYS); assert!(t.try_make_space_for_new_relay().await.is_err()); assert_eq!(t.count_transports().await?, MAX_RELAYS); Ok(()) } /// Tests that if Alice adds maximum number of transports, /// Bob sends messages to all of them. /// /// This way we don't need to care about the order /// of addresses advertised in the public key. /// Previously the number of addresses /// taken from the key was less than the maximum /// number of advertised addresses. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_can_send_to_max_relays() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; // One relay is added already by default. for i in 1..MAX_RELAYS { add_pseudo_transport(alice, &format!("transport{i}@example.org")).await?; } assert_eq!(alice.count_transports().await?, MAX_RELAYS); let bob_chat_id = bob.create_chat_id(alice).await; bob.set_config_bool(Config::BccSelf, false).await?; let sent = bob.send_text(bob_chat_id, "Hello!").await; assert_eq!( sent.recipients.split(' ').count(), MAX_RELAYS, "List of recipients is {}", sent.recipients ); Ok(()) } }