From 637e37b1c764b50db9f055519d2b96768629bf7c Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 5 Nov 2025 02:01:31 +0000 Subject: [PATCH 01/35] feat: restart I/O when setting configured_addr --- src/config.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 9c716fd614..7c41917a81 100644 --- a/src/config.rs +++ b/src/config.rs @@ -477,7 +477,10 @@ impl Config { /// Whether the config option needs an IO scheduler restart to take effect. pub(crate) fn needs_io_restart(&self) -> bool { - matches!(self, Config::MvboxMove | Config::OnlyFetchMvbox) + matches!( + self, + Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr + ) } } From eaad58d2ffaae37a69d8b600055ced35b6c2f6ad Mon Sep 17 00:00:00 2001 From: link2xt Date: Sat, 1 Nov 2025 13:40:10 +0000 Subject: [PATCH 02/35] refactor: return transport ID from ConfiguredLoginParam::load() --- src/context.rs | 7 ++++--- src/imap.rs | 2 +- src/smtp.rs | 2 +- src/transport.rs | 30 +++++++++++++++++------------- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/context.rs b/src/context.rs index 0ca01a0445..7ccaf64a43 100644 --- a/src/context.rs +++ b/src/context.rs @@ -807,9 +807,10 @@ impl Context { /// Returns information about the context as key-value pairs. pub async fn get_info(&self) -> Result> { let l = EnteredLoginParam::load(self).await?; - let l2 = ConfiguredLoginParam::load(self) - .await? - .map_or_else(|| "Not configured".to_string(), |param| param.to_string()); + let l2 = ConfiguredLoginParam::load(self).await?.map_or_else( + || "Not configured".to_string(), + |(_transport_id, param)| param.to_string(), + ); let secondary_addrs = self.get_secondary_self_addrs().await?.join(", "); let chats = get_chat_cnt(self).await?; let unblocked_msgs = message::get_unblocked_msg_cnt(self).await; diff --git a/src/imap.rs b/src/imap.rs index 5614f6593a..2b199b9ccd 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -285,7 +285,7 @@ impl Imap { context: &Context, idle_interrupt_receiver: Receiver<()>, ) -> Result { - let param = ConfiguredLoginParam::load(context) + let (_transport_id, param) = ConfiguredLoginParam::load(context) .await? .context("Not configured")?; let proxy_config = ProxyConfig::load(context).await?; diff --git a/src/smtp.rs b/src/smtp.rs index a34c776b31..0a4ef049a5 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -89,7 +89,7 @@ impl Smtp { } self.connectivity.set_connecting(context); - let lp = ConfiguredLoginParam::load(context) + let (_transport_id, lp) = ConfiguredLoginParam::load(context) .await? .context("Not configured")?; let proxy_config = ProxyConfig::load(context).await?; diff --git a/src/transport.rs b/src/transport.rs index 88bfbcb245..ed63d02b41 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -241,23 +241,27 @@ impl ConfiguredLoginParam { /// Load configured account settings from the database. /// /// Returns `None` if account is not configured. - pub(crate) async fn load(context: &Context) -> Result> { + pub(crate) async fn load(context: &Context) -> Result> { let Some(self_addr) = context.get_config(Config::ConfiguredAddr).await? else { return Ok(None); }; - let json: Option = context + let Some((id, json)) = context .sql - .query_get_value( - "SELECT configured_param FROM transports WHERE addr=?", + .query_row_optional( + "SELECT id, configured_param FROM transports WHERE addr=?", (&self_addr,), + |row| { + let id: u32 = row.get(0)?; + let json: String = row.get(1)?; + Ok((id, json)) + }, ) - .await?; - if let Some(json) = json { - Ok(Some(Self::from_json(&json)?)) - } else { + .await? + else { bail!("Self address {self_addr} doesn't have a corresponding transport"); - } + }; + Ok(Some((id, Self::from_json(&json)?))) } /// Loads legacy configured param. Only used for tests and the migration. @@ -680,7 +684,7 @@ mod tests { expected_param ); assert_eq!(t.is_configured().await?, true); - let loaded = ConfiguredLoginParam::load(&t).await?.unwrap(); + let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap(); assert_eq!(param, loaded); // Legacy ConfiguredImapCertificateChecks config is ignored @@ -789,7 +793,7 @@ mod tests { assert_eq!(loaded, param); migrate_configured_login_param(&t).await; - let loaded = ConfiguredLoginParam::load(&t).await?.unwrap(); + let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap(); assert_eq!(loaded, param); Ok(()) @@ -833,7 +837,7 @@ mod tests { migrate_configured_login_param(&t).await; - let loaded = ConfiguredLoginParam::load(&t).await?.unwrap(); + let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap(); assert_eq!(loaded.provider, Some(*provider)); assert_eq!(loaded.imap.is_empty(), false); assert_eq!(loaded.smtp.is_empty(), false); @@ -890,7 +894,7 @@ mod tests { .save_to_transports_table(&t, &EnteredLoginParam::default()) .await?; - let loaded = ConfiguredLoginParam::load(&t).await?.unwrap(); + let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap(); assert_eq!(loaded.provider, Some(*provider)); assert_eq!(loaded.imap.is_empty(), false); assert_eq!(loaded.smtp.is_empty(), false); From 1ea8c50d993c80510abb93ac494d3efbd7b54eb8 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 24 Oct 2025 10:26:05 +0000 Subject: [PATCH 03/35] feat: allow adding second transport --- .../src/deltachat_rpc_client/pytestplugin.py | 9 +++++++-- deltachat-rpc-client/tests/test_multitransport.py | 7 +++++++ src/configure.rs | 6 ------ src/transport.rs | 10 ++-------- 4 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 deltachat-rpc-client/tests/test_multitransport.py diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py index 1516b8f718..c1a01598aa 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py @@ -40,12 +40,17 @@ def get_credentials(self) -> (str, str): username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6)) return f"{username}@{domain}", f"{username}${username}" + def get_account_qr(self): + """Return "dcaccount:" QR code for testing chatmail relay.""" + domain = os.getenv("CHATMAIL_DOMAIN") + return f"dcaccount:{domain}" + @futuremethod def new_configured_account(self): """Create a new configured account.""" account = self.get_unconfigured_account() - domain = os.getenv("CHATMAIL_DOMAIN") - yield account.add_transport_from_qr.future(f"dcaccount:{domain}") + qr = self.get_account_qr() + yield account.add_transport_from_qr.future(qr) assert account.is_configured() return account diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py new file mode 100644 index 0000000000..a69eaec777 --- /dev/null +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -0,0 +1,7 @@ +def test_add_second_address(acfactory) -> None: + account = acfactory.new_configured_account() + assert len(account.list_transports()) == 1 + + qr = acfactory.get_account_qr() + account.add_transport_from_qr(qr) + assert len(account.list_transports()) == 2 diff --git a/src/configure.rs b/src/configure.rs index 5b76b0920c..b4491bd4b7 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -130,12 +130,6 @@ impl Context { "cannot configure, database not opened." ); param.addr = addr_normalize(¶m.addr); - let old_addr = self.get_config(Config::ConfiguredAddr).await?; - if self.is_configured().await? && !addr_cmp(&old_addr.unwrap_or_default(), ¶m.addr) { - let error_msg = "Changing your email address is not supported right now. Check back in a few months!"; - progress!(self, 0, Some(error_msg.to_string())); - bail!(error_msg); - } let cancel_channel = self.alloc_ongoing().await?; let res = self diff --git a/src/transport.rs b/src/transport.rs index ed63d02b41..86181d48f8 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -10,8 +10,8 @@ use std::fmt; -use anyhow::{Context as _, Result, bail, ensure, format_err}; -use deltachat_contact_tools::{EmailAddress, addr_cmp, addr_normalize}; +use anyhow::{Context as _, Result, bail, format_err}; +use deltachat_contact_tools::{EmailAddress, addr_normalize}; use serde::{Deserialize, Serialize}; use crate::config::Config; @@ -540,12 +540,6 @@ impl ConfiguredLoginParam { let addr = addr_normalize(&self.addr); let provider_id = self.provider.map(|provider| provider.id); let configured_addr = context.get_config(Config::ConfiguredAddr).await?; - if let Some(configured_addr) = &configured_addr { - ensure!( - addr_cmp(configured_addr, &addr), - "Adding a second transport is not supported right now." - ); - } context .sql .execute( From 71383c6af8a8c312319b748790c65afa61611bed Mon Sep 17 00:00:00 2001 From: link2xt Date: Sat, 25 Oct 2025 01:08:19 +0000 Subject: [PATCH 04/35] feat: allow deleting transports --- .../src/deltachat_rpc_client/account.py | 4 +++ .../tests/test_multitransport.py | 17 +++++++++++++ src/configure.rs | 25 +++++++++++++++---- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index 19109b4929..522e6e5eb2 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -130,6 +130,10 @@ def add_transport_from_qr(self, qr: str): """Add a new transport using a QR code.""" yield self._rpc.add_transport_from_qr.future(self.id, qr) + def delete_transport(self, addr: str): + """Delete a transport.""" + self._rpc.delete_transport(self.id, addr) + @futuremethod def list_transports(self): """Return the list of all email accounts that are used as a transport in the current profile.""" diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index a69eaec777..347036ce63 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -1,3 +1,7 @@ +import pytest + +from deltachat_rpc_client.rpc import JsonRpcError + def test_add_second_address(acfactory) -> None: account = acfactory.new_configured_account() assert len(account.list_transports()) == 1 @@ -5,3 +9,16 @@ def test_add_second_address(acfactory) -> None: qr = acfactory.get_account_qr() account.add_transport_from_qr(qr) assert len(account.list_transports()) == 2 + + account.add_transport_from_qr(qr) + assert len(account.list_transports()) == 3 + + first_addr = account.list_transports()[0]["addr"] + second_addr = account.list_transports()[1]["addr"] + + # Cannot delete the first address. + with pytest.raises(JsonRpcError): + account.delete_transport(first_addr) + + account.delete_transport(second_addr) + assert len(account.list_transports()) == 2 diff --git a/src/configure.rs b/src/configure.rs index b4491bd4b7..abd2c04f57 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -200,11 +200,26 @@ impl Context { /// Removes the transport with the specified email address /// (i.e. [EnteredLoginParam::addr]). - #[expect(clippy::unused_async)] - pub async fn delete_transport(&self, _addr: &str) -> Result<()> { - bail!( - "Adding and removing additional transports is not supported yet. Check back in a few months!" - ) + pub async fn delete_transport(&self, addr: &str) -> Result<()> { + self.sql + .transaction(|transaction| { + let current_addr = transaction.query_row( + "SELECT value FROM config WHERE keyname='configured_addr'", + (), + |row| { + let addr: String = row.get(0)?; + Ok(addr) + }, + )?; + + if current_addr == addr { + bail!("Cannot delete current transport"); + } + transaction.execute("DELETE FROM transports WHERE addr=?", (addr,))?; + Ok(()) + }) + .await?; + Ok(()) } async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> { From 389b1ce275875d16f3889592ee0d6091d5373748 Mon Sep 17 00:00:00 2001 From: link2xt Date: Sun, 26 Oct 2025 07:34:34 +0000 Subject: [PATCH 05/35] feat: allow to set another transport as primary --- .../tests/test_multitransport.py | 32 +++++++++++++++++++ src/config.rs | 28 ++++++++-------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index 347036ce63..e6df791dd3 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -2,6 +2,7 @@ from deltachat_rpc_client.rpc import JsonRpcError + def test_add_second_address(acfactory) -> None: account = acfactory.new_configured_account() assert len(account.list_transports()) == 1 @@ -22,3 +23,34 @@ def test_add_second_address(acfactory) -> None: account.delete_transport(second_addr) assert len(account.list_transports()) == 2 + + +def test_change_address(acfactory) -> None: + """Test Alice configuring a second transport and setting it as a primary one.""" + alice, bob = acfactory.get_online_accounts(2) + + bob.create_chat(alice) + + alice_chat_bob = alice.create_chat(bob) + alice_chat_bob.send_text("Hello!") + + msg1 = bob.wait_for_incoming_msg().get_snapshot() + sender_addr1 = msg1.sender.get_snapshot().address + + alice.stop_io() + old_alice_addr = alice.get_config("configured_addr") + qr = acfactory.get_account_qr() + alice.add_transport_from_qr(qr) + new_alice_addr = alice.list_transports()[1]["addr"] + alice.set_config("configured_addr", new_alice_addr) + alice.start_io() + + alice_chat_bob.send_text("Hello again!") + + msg2 = bob.wait_for_incoming_msg().get_snapshot() + sender_addr2 = msg2.sender.get_snapshot().address + + assert msg1.sender == msg2.sender + assert sender_addr1 != sender_addr2 + assert sender_addr1 == old_alice_addr + assert sender_addr2 == new_alice_addr diff --git a/src/config.rs b/src/config.rs index 7c41917a81..8e487abceb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use std::env; use std::path::Path; use std::str::FromStr; -use anyhow::{Context as _, Result, bail, ensure}; +use anyhow::{Context as _, Result, ensure}; use base64::Engine as _; use deltachat_contact_tools::{addr_cmp, sanitize_single_line}; use serde::{Deserialize, Serialize}; @@ -801,20 +801,20 @@ impl Context { .await?; } Config::ConfiguredAddr => { - if self.is_configured().await? { - bail!("Cannot change ConfiguredAddr"); - } - if let Some(addr) = value { - info!( - self, - "Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!" - ); - ConfiguredLoginParam::from_json(&format!( - r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"# - ))? - .save_to_transports_table(self, &EnteredLoginParam::default()) - .await?; + if !self.is_configured().await? { + if let Some(addr) = value { + info!( + self, + "Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!" + ); + ConfiguredLoginParam::from_json(&format!( + r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"# + ))? + .save_to_transports_table(self, &EnteredLoginParam::default()) + .await?; + } } + self.sql.set_raw_config(key.as_ref(), value).await?; } _ => { self.sql.set_raw_config(key.as_ref(), value).await?; From 850b1ac37758ab4361498d631d5d7966c0a3ffb9 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 31 Oct 2025 14:33:02 +0000 Subject: [PATCH 06/35] feat: add transport column to imap table --- src/ephemeral/ephemeral_tests.rs | 6 ++++-- src/imap.rs | 25 +++++++++++++++++-------- src/sql/migrations.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/ephemeral/ephemeral_tests.rs b/src/ephemeral/ephemeral_tests.rs index 1a99ff6afe..693c1b8722 100644 --- a/src/ephemeral/ephemeral_tests.rs +++ b/src/ephemeral/ephemeral_tests.rs @@ -451,6 +451,8 @@ async fn test_delete_expired_imap_messages() -> Result<()> { let t = TestContext::new_alice().await; const HOUR: i64 = 60 * 60; let now = time(); + let transport_id = 1; + let uidvalidity = 12345; for (id, timestamp, ephemeral_timestamp) in &[ (900, now - 2 * HOUR, 0), (1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0), @@ -470,8 +472,8 @@ async fn test_delete_expired_imap_messages() -> Result<()> { .await?; t.sql .execute( - "INSERT INTO imap (rfc724_mid, folder, uid, target) VALUES (?,'INBOX',?, 'INBOX');", - (&message_id, id), + "INSERT INTO imap (transport_id, rfc724_mid, folder, uid, target, uidvalidity) VALUES (?, ?,'INBOX',?, 'INBOX', ?);", + (transport_id, &message_id, id, uidvalidity), ) .await?; } diff --git a/src/imap.rs b/src/imap.rs index 2b199b9ccd..61a51dd108 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -659,15 +659,23 @@ impl Imap { &_target }; + let transport_id = 1; // FIXME context .sql .execute( - "INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(folder, uid, uidvalidity) + "INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(transport_id, folder, uid, uidvalidity) DO UPDATE SET rfc724_mid=excluded.rfc724_mid, target=excluded.target", - (&message_id, &folder, uid, uid_validity, target), + ( + transport_id, + &message_id, + &folder, + uid, + uid_validity, + target, + ), ) .await?; @@ -896,6 +904,7 @@ impl Session { uid_validity = 0; } + let transport_id = 1; // FIXME // Write collected UIDs to SQLite database. context .sql @@ -905,12 +914,12 @@ impl Session { // This may detect previously undetected moved // messages, so we update server_folder too. transaction.execute( - "INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(folder, uid, uidvalidity) + "INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(transport_id, folder, uid, uidvalidity) DO UPDATE SET rfc724_mid=excluded.rfc724_mid, target=excluded.target", - (rfc724_mid, folder, uid, uid_validity, target), + (transport_id, rfc724_mid, folder, uid, uid_validity, target), )?; } Ok(()) diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 8961eb09e9..1025e62550 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1402,6 +1402,33 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); .await?; } + inc_and_check(&mut migration_version, 140)?; + if dbversion < migration_version { + sql.execute_migration( + "CREATE TABLE new_imap ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +transport_id INTEGER NOT NULL, -- ID of the transport in the `transports` table. +rfc724_mid TEXT NOT NULL, -- Message-ID header +folder TEXT NOT NULL, -- IMAP folder +target TEXT NOT NULL, -- Destination folder, empty to delete. +uid INTEGER NOT NULL, -- UID +uidvalidity INTEGER NOT NULL, +UNIQUE (transport_id, folder, uid, uidvalidity) +) STRICT; + +INSERT OR IGNORE INTO new_imap SELECT + id, 1, rfc724_mid, folder, target, uid, uidvalidity +FROM imap; +DROP TABLE imap; +ALTER TABLE new_imap RENAME TO imap; +CREATE INDEX imap_folder ON imap(folder); +CREATE INDEX imap_messageid ON imap(rfc724_mid); +", + migration_version, + ) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await? From dccf40685119b450661b368478641ee21632332f Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 31 Oct 2025 18:21:55 +0000 Subject: [PATCH 07/35] feat: add transport column to imap_sync table --- src/imap.rs | 21 ++++++++++++--------- src/sql/migrations.rs | 22 +++++++++++++++++++--- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/imap.rs b/src/imap.rs index 61a51dd108..df0a375957 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -2427,12 +2427,13 @@ pub(crate) async fn markseen_on_imap_table(context: &Context, message_id: &str) /// See /// This function is used to update our uid_next after fetching messages. pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32) -> Result<()> { + let transport_id = 1; // FIXME context .sql .execute( - "INSERT INTO imap_sync (folder, uid_next) VALUES (?,?) - ON CONFLICT(folder) DO UPDATE SET uid_next=excluded.uid_next", - (folder, uid_next), + "INSERT INTO imap_sync (transport_id, folder, uid_next) VALUES (?, ?,?) + ON CONFLICT(transport_id, folder) DO UPDATE SET uid_next=excluded.uid_next", + (transport_id, folder, uid_next), ) .await?; Ok(()) @@ -2456,12 +2457,13 @@ pub(crate) async fn set_uidvalidity( folder: &str, uidvalidity: u32, ) -> Result<()> { + let transport_id = 1; context .sql .execute( - "INSERT INTO imap_sync (folder, uidvalidity) VALUES (?,?) - ON CONFLICT(folder) DO UPDATE SET uidvalidity=excluded.uidvalidity", - (folder, uidvalidity), + "INSERT INTO imap_sync (transport_id, folder, uidvalidity) VALUES (?,?,?) + ON CONFLICT(transport_id, folder) DO UPDATE SET uidvalidity=excluded.uidvalidity", + (transport_id, folder, uidvalidity), ) .await?; Ok(()) @@ -2479,12 +2481,13 @@ async fn get_uidvalidity(context: &Context, folder: &str) -> Result { } pub(crate) async fn set_modseq(context: &Context, folder: &str, modseq: u64) -> Result<()> { + let transport_id = 1; // FIXME context .sql .execute( - "INSERT INTO imap_sync (folder, modseq) VALUES (?,?) - ON CONFLICT(folder) DO UPDATE SET modseq=excluded.modseq", - (folder, modseq), + "INSERT INTO imap_sync (transport_id, folder, modseq) VALUES (?,?,?) + ON CONFLICT(transport_id, folder) DO UPDATE SET modseq=excluded.modseq", + (transport_id, folder, modseq), ) .await?; Ok(()) diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 1025e62550..9b9bc3bde7 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -1405,7 +1405,8 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); inc_and_check(&mut migration_version, 140)?; if dbversion < migration_version { sql.execute_migration( - "CREATE TABLE new_imap ( + " +CREATE TABLE new_imap ( id INTEGER PRIMARY KEY AUTOINCREMENT, transport_id INTEGER NOT NULL, -- ID of the transport in the `transports` table. rfc724_mid TEXT NOT NULL, -- Message-ID header @@ -1421,8 +1422,23 @@ INSERT OR IGNORE INTO new_imap SELECT FROM imap; DROP TABLE imap; ALTER TABLE new_imap RENAME TO imap; -CREATE INDEX imap_folder ON imap(folder); -CREATE INDEX imap_messageid ON imap(rfc724_mid); +CREATE INDEX imap_folder ON imap(transport_id, folder); +CREATE INDEX imap_rfc724_mid ON imap(transport_id, rfc724_mid); + +CREATE TABLE new_imap_sync ( + transport_id INTEGER NOT NULL, -- ID of the transport in the `transports` table. + folder TEXT NOT NULL, + uidvalidity INTEGER NOT NULL DEFAULT 0, + uid_next INTEGER NOT NULL DEFAULT 0, + modseq INTEGER NOT NULL DEFAULT 0, + UNIQUE (transport_id, folder) +) STRICT; +INSERT OR IGNORE INTO new_imap_sync SELECT + 1, folder, uidvalidity, uid_next, modseq +FROM imap_sync; +DROP TABLE imap_sync; +ALTER TABLE new_imap_sync RENAME TO imap_sync; +CREATE INDEX imap_sync_index ON imap_sync(transport_id, folder); ", migration_version, ) From faa1a641a82c69c3c08a158644afe742b96dee5b Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 31 Oct 2025 22:34:29 +0000 Subject: [PATCH 08/35] api: add count_transports() --- src/configure.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/configure.rs b/src/configure.rs index abd2c04f57..a066d7fcc5 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -198,6 +198,11 @@ impl Context { Ok(transports) } + /// Returns the number of configured transports. + pub async fn count_transports(&self) -> Result { + self.sql.count("SELECT COUNT(*) FROM transports", ()).await + } + /// Removes the transport with the specified email address /// (i.e. [EnteredLoginParam::addr]). pub async fn delete_transport(&self, addr: &str) -> Result<()> { From eb4667b8ff2ac4e0586e244d35bfca33989f3ba4 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 31 Oct 2025 22:34:29 +0000 Subject: [PATCH 09/35] feat: require mvbox_move and only_fetch_mvbox to be disabled for multi-transport --- .../tests/test_multitransport.py | 12 ++++++++++++ src/config.rs | 7 ++++++- src/configure.rs | 16 +++++++++++----- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index e6df791dd3..3ffd684bf2 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -7,6 +7,12 @@ def test_add_second_address(acfactory) -> None: account = acfactory.new_configured_account() assert len(account.list_transports()) == 1 + # When the first transport is created, + # mvbox_move and only_fetch_mvbox should be disabled. + assert account.get_config("mvbox_move") == "0" + assert account.get_config("only_fetch_mvbox") == "0" + assert account.get_config("show_emails") == "2" + qr = acfactory.get_account_qr() account.add_transport_from_qr(qr) assert len(account.list_transports()) == 2 @@ -24,6 +30,12 @@ def test_add_second_address(acfactory) -> None: account.delete_transport(second_addr) assert len(account.list_transports()) == 2 + # Enabling mvbox_move or only_fetch_mvbox + # is not allowed when multi-transport is enabled. + for option in ["mvbox_move", "only_fetch_mvbox"]: + with pytest.raises(JsonRpcError): + account.set_config(option, "1") + def test_change_address(acfactory) -> None: """Test Alice configuring a second transport and setting it as a primary one.""" diff --git a/src/config.rs b/src/config.rs index 8e487abceb..f82da3fbb6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use std::env; use std::path::Path; use std::str::FromStr; -use anyhow::{Context as _, Result, ensure}; +use anyhow::{Context as _, Result, bail, ensure}; use base64::Engine as _; use deltachat_contact_tools::{addr_cmp, sanitize_single_line}; use serde::{Deserialize, Serialize}; @@ -716,6 +716,11 @@ impl Context { pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> { Self::check_config(key, value)?; + let n_transports = self.count_transports().await?; + if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) { + bail!("Cannot reconfigure {key} when multiple transports are configured"); + } + let _pause = match key.needs_io_restart() { true => self.scheduler.pause(self).await?, _ => Default::default(), diff --git a/src/configure.rs b/src/configure.rs index a066d7fcc5..d3eb0a50cd 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -231,6 +231,15 @@ impl Context { info!(self, "Configure ..."); let old_addr = self.get_config(Config::ConfiguredAddr).await?; + if old_addr.is_some() { + if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") { + bail!("Cannot use multi-transport with mvbox_move enabled."); + } + if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") { + bail!("Cannot use multi-transport with only_fetch_mvbox enabled."); + } + } + let provider = configure(self, param).await?; self.set_config_internal(Config::NotifyAboutWrongPw, Some("1")) .await?; @@ -558,11 +567,8 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result ctx.get_config_bool(Config::IsChatmail).await?, }; - if is_chatmail { - ctx.set_config(Config::MvboxMove, Some("0")).await?; - ctx.set_config(Config::OnlyFetchMvbox, None).await?; - ctx.set_config(Config::ShowEmails, None).await?; - } + ctx.sql.set_raw_config("mvbox_move", Some("0")).await?; + ctx.sql.set_raw_config("only_fetch_mvbox", None).await?; let create_mvbox = !is_chatmail; imap.configure_folders(ctx, &mut imap_session, create_mvbox) From db5b6bfcc4fc91c8d8d9c381cb16c58b855fde60 Mon Sep 17 00:00:00 2001 From: link2xt Date: Sat, 1 Nov 2025 13:40:10 +0000 Subject: [PATCH 10/35] load transport ID with ConfiguredLoginParam --- src/configure.rs | 2 ++ src/imap.rs | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/configure.rs b/src/configure.rs index d3eb0a50cd..d5995336b7 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -526,8 +526,10 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result, /// Email address. @@ -251,7 +256,9 @@ impl Imap { /// Creates new disconnected IMAP client using the specific login parameters. /// /// `addr` is used to renew token if OAuth2 authentication is used. + #[expect(clippy::too_many_arguments)] pub fn new( + transport_id: u32, lp: Vec, password: String, proxy_config: Option, @@ -262,6 +269,7 @@ impl Imap { ) -> Self { let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1); Imap { + transport_id, idle_interrupt_receiver, addr: addr.to_string(), lp, @@ -285,12 +293,13 @@ impl Imap { context: &Context, idle_interrupt_receiver: Receiver<()>, ) -> Result { - let (_transport_id, param) = ConfiguredLoginParam::load(context) + let (transport_id, param) = ConfiguredLoginParam::load(context) .await? .context("Not configured")?; let proxy_config = ProxyConfig::load(context).await?; let strict_tls = param.strict_tls(proxy_config.is_some()); let imap = Self::new( + transport_id, param.imap.clone(), param.imap_password.clone(), proxy_config, @@ -659,7 +668,6 @@ impl Imap { &_target }; - let transport_id = 1; // FIXME context .sql .execute( @@ -669,7 +677,7 @@ impl Imap { DO UPDATE SET rfc724_mid=excluded.rfc724_mid, target=excluded.target", ( - transport_id, + self.transport_id, &message_id, &folder, uid, From 0933ef5721d7253820a54dea1ecd85f18960d1a1 Mon Sep 17 00:00:00 2001 From: link2xt Date: Sat, 1 Nov 2025 22:30:50 +0000 Subject: [PATCH 11/35] test: test that second transport cannot be set up if mvbox is used --- .../tests/test_multitransport.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index 3ffd684bf2..401fa1ee04 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -37,6 +37,22 @@ def test_add_second_address(acfactory) -> None: account.set_config(option, "1") +@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"]) +def test_no_second_transport_with_mvbox(acfactory, key) -> None: + """Test that second transport cannot be configured if mvbox is used.""" + account = acfactory.new_configured_account() + assert len(account.list_transports()) == 1 + + assert account.get_config("mvbox_move") == "0" + assert account.get_config("only_fetch_mvbox") == "0" + + qr = acfactory.get_account_qr() + account.set_config(key, "1") + + with pytest.raises(JsonRpcError): + account.add_transport_from_qr(qr) + + def test_change_address(acfactory) -> None: """Test Alice configuring a second transport and setting it as a primary one.""" alice, bob = acfactory.get_online_accounts(2) From 2543a4495ee40a960985ea8953503fb5a6e20990 Mon Sep 17 00:00:00 2001 From: link2xt Date: Sun, 2 Nov 2025 20:59:16 +0000 Subject: [PATCH 12/35] test: current transport cannot be deleted after changing to it --- deltachat-rpc-client/tests/test_multitransport.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index 401fa1ee04..5b9a139080 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -71,6 +71,8 @@ def test_change_address(acfactory) -> None: alice.add_transport_from_qr(qr) new_alice_addr = alice.list_transports()[1]["addr"] alice.set_config("configured_addr", new_alice_addr) + with pytest.raises(JsonRpcError): + alice.delete_transport(new_alice_addr) alice.start_io() alice_chat_bob.send_text("Hello again!") From 26be69d231ea1b1981f2630999583fe62d83ed88 Mon Sep 17 00:00:00 2001 From: link2xt Date: Sun, 2 Nov 2025 21:19:02 +0000 Subject: [PATCH 13/35] fix: do not allow to set ConfiguredAddr to arbitrary values --- .../tests/test_multitransport.py | 5 +++++ src/config.rs | 21 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index 5b9a139080..8c59b60f90 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -57,6 +57,7 @@ def test_change_address(acfactory) -> None: """Test Alice configuring a second transport and setting it as a primary one.""" alice, bob = acfactory.get_online_accounts(2) + bob_addr = bob.get_config("configured_addr") bob.create_chat(alice) alice_chat_bob = alice.create_chat(bob) @@ -70,6 +71,10 @@ def test_change_address(acfactory) -> None: qr = acfactory.get_account_qr() alice.add_transport_from_qr(qr) new_alice_addr = alice.list_transports()[1]["addr"] + with pytest.raises(JsonRpcError): + # Cannot use the address that is not + # configured for any transport. + alice.set_config("configured_addr", bob_addr) alice.set_config("configured_addr", new_alice_addr) with pytest.raises(JsonRpcError): alice.delete_transport(new_alice_addr) diff --git a/src/config.rs b/src/config.rs index f82da3fbb6..83099bdf33 100644 --- a/src/config.rs +++ b/src/config.rs @@ -819,7 +819,26 @@ impl Context { .await?; } } - self.sql.set_raw_config(key.as_ref(), value).await?; + self.sql + .transaction(|transaction| { + if transaction.query_row( + "SELECT COUNT(*) FROM transports WHERE addr=?", + (value,), + |row| { + let res: i64 = row.get(0)?; + Ok(res) + }, + )? == 0 + { + bail!("Address does not belong to any transport."); + } + transaction.execute( + "UPDATE config SET value=? WHERE keyname='configured_addr'", + (value,), + )?; + Ok(()) + }) + .await?; } _ => { self.sql.set_raw_config(key.as_ref(), value).await?; From 1a0aec7fd7f8f1f5ce960f58ff04ddf04285e9a5 Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 3 Nov 2025 15:26:44 +0000 Subject: [PATCH 14/35] ConfiguredLoginParam::load_all() --- src/transport.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/transport.rs b/src/transport.rs index 86181d48f8..f8c7a174f5 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -264,6 +264,25 @@ impl ConfiguredLoginParam { Ok(Some((id, Self::from_json(&json)?))) } + /// Loads configured login parameters for all transports. + pub(crate) async fn load_all(context: &Context) -> Result> { + context + .sql + .query_map("SELECT id, configured_param FROM transports", (), |row| { + let id: u32 = row.get(0)?; + let json: String = row.get(1)?; + Ok((id, json)) + }, |rows| { + let mut res = Vec::new(); + for row in rows { + let (id, json) = row?; + res.push((id, Self::from_json(&json)?)); + } + Ok(res) + }) + .await + } + /// Loads legacy configured param. Only used for tests and the migration. pub(crate) async fn load_legacy(context: &Context) -> Result> { if !context.get_config_bool(Config::Configured).await? { From 9e8c0596ffec79b80e3ce85821094693b34373b5 Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 3 Nov 2025 19:07:12 +0000 Subject: [PATCH 15/35] Imap::new() accepting login params --- src/configure.rs | 11 +---------- src/imap.rs | 38 +++++++++++++------------------------- src/scheduler.rs | 19 +++++++++++++++---- src/transport.rs | 14 ++++---------- 4 files changed, 33 insertions(+), 49 deletions(-) diff --git a/src/configure.rs b/src/configure.rs index d5995336b7..4bef113373 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -528,16 +528,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result session, diff --git a/src/imap.rs b/src/imap.rs index c9824f3e4b..ad39c14945 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -254,21 +254,20 @@ impl> Iterator for UidGrouper { impl Imap { /// Creates new disconnected IMAP client using the specific login parameters. - /// - /// `addr` is used to renew token if OAuth2 authentication is used. - #[expect(clippy::too_many_arguments)] - pub fn new( + pub async fn new( + context: &Context, transport_id: u32, - lp: Vec, - password: String, - proxy_config: Option, - addr: &str, - strict_tls: bool, - oauth2: bool, + param: ConfiguredLoginParam, idle_interrupt_receiver: Receiver<()>, - ) -> Self { + ) -> Result { + let lp = param.imap.clone(); + let password = param.imap_password.clone(); + let proxy_config = ProxyConfig::load(context).await?; + let addr = ¶m.addr; + let strict_tls = param.strict_tls(proxy_config.is_some()); + let oauth2 = param.oauth2; let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1); - Imap { + Ok(Imap { transport_id, idle_interrupt_receiver, addr: addr.to_string(), @@ -285,7 +284,7 @@ impl Imap { ratelimit: Ratelimit::new(Duration::new(120, 0), 2.0), resync_request_sender, resync_request_receiver, - } + }) } /// Creates new disconnected IMAP client using configured parameters. @@ -296,18 +295,7 @@ impl Imap { let (transport_id, param) = ConfiguredLoginParam::load(context) .await? .context("Not configured")?; - let proxy_config = ProxyConfig::load(context).await?; - let strict_tls = param.strict_tls(proxy_config.is_some()); - let imap = Self::new( - transport_id, - param.imap.clone(), - param.imap_password.clone(), - proxy_config, - ¶m.addr, - strict_tls, - param.oauth2, - idle_interrupt_receiver, - ); + let imap = Self::new(context, transport_id, param, idle_interrupt_receiver).await?; Ok(imap) } diff --git a/src/scheduler.rs b/src/scheduler.rs index 97f90e85ce..f5e0f595c8 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -26,6 +26,7 @@ use crate::smtp::{Smtp, send_smtp_messages}; use crate::sql; use crate::stats::maybe_send_stats; use crate::tools::{self, duration_to_str, maybe_add_time_based_warnings, time, time_elapsed}; +use crate::transport::ConfiguredLoginParam; use crate::{constants, stats}; pub(crate) mod connectivity; @@ -860,7 +861,11 @@ impl Scheduler { let mut oboxes = Vec::new(); let mut start_recvs = Vec::new(); - let (conn_state, inbox_handlers) = ImapConnectionState::new(ctx).await?; + let (transport_id, configured_login_param) = ConfiguredLoginParam::load(ctx) + .await? + .context("Not configured")?; + let (conn_state, inbox_handlers) = + ImapConnectionState::new(ctx, transport_id, configured_login_param.clone()).await?; let (inbox_start_send, inbox_start_recv) = oneshot::channel(); let handle = { let ctx = ctx.clone(); @@ -874,7 +879,8 @@ impl Scheduler { start_recvs.push(inbox_start_recv); if ctx.should_watch_mvbox().await? { - let (conn_state, handlers) = ImapConnectionState::new(ctx).await?; + let (conn_state, handlers) = + ImapConnectionState::new(ctx, transport_id, configured_login_param).await?; let (start_send, start_recv) = oneshot::channel(); let ctx = ctx.clone(); let meaning = FolderMeaning::Mvbox; @@ -1095,12 +1101,17 @@ pub(crate) struct ImapConnectionState { impl ImapConnectionState { /// Construct a new connection. - async fn new(context: &Context) -> Result<(Self, ImapConnectionHandlers)> { + async fn new( + context: &Context, + transport_id: u32, + login_param: ConfiguredLoginParam, + ) -> Result<(Self, ImapConnectionHandlers)> { let stop_token = CancellationToken::new(); let (idle_interrupt_sender, idle_interrupt_receiver) = channel::bounded(1); let handlers = ImapConnectionHandlers { - connection: Imap::new_configured(context, idle_interrupt_receiver).await?, + connection: Imap::new(context, transport_id, login_param, idle_interrupt_receiver) + .await?, stop_token: stop_token.clone(), }; diff --git a/src/transport.rs b/src/transport.rs index f8c7a174f5..8d53734f59 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -265,20 +265,14 @@ impl ConfiguredLoginParam { } /// Loads configured login parameters for all transports. - pub(crate) async fn load_all(context: &Context) -> Result> { + pub(crate) async fn load_all(context: &Context) -> Result> { context .sql - .query_map("SELECT id, configured_param FROM transports", (), |row| { + .query_map_vec("SELECT id, configured_param FROM transports", (), |row| { let id: u32 = row.get(0)?; let json: String = row.get(1)?; - Ok((id, json)) - }, |rows| { - let mut res = Vec::new(); - for row in rows { - let (id, json) = row?; - res.push((id, Self::from_json(&json)?)); - } - Ok(res) + let param = Self::from_json(&json)?; + Ok((id, param)) }) .await } From 9b1788dafede75ad16467cc00ad3630352815817 Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 3 Nov 2025 20:16:58 +0000 Subject: [PATCH 16/35] start IMAP loops for all transports --- src/scheduler.rs | 86 +++++++++++++++++++---------------- src/scheduler/connectivity.rs | 25 +++++----- 2 files changed, 60 insertions(+), 51 deletions(-) diff --git a/src/scheduler.rs b/src/scheduler.rs index f5e0f595c8..542d567998 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -1,5 +1,5 @@ use std::cmp; -use std::iter::{self, once}; +use std::iter; use std::num::NonZeroUsize; use anyhow::{Context as _, Error, Result, bail}; @@ -213,21 +213,25 @@ impl SchedulerState { /// Indicate that the network likely has come back. pub(crate) async fn maybe_network(&self) { let inner = self.inner.read().await; - let (inbox, oboxes) = match *inner { + let (inboxes, oboxes) = match *inner { InnerSchedulerState::Started(ref scheduler) => { scheduler.maybe_network(); - let inbox = scheduler.inbox.conn_state.state.connectivity.clone(); + let inboxes = scheduler + .inboxes + .iter() + .map(|b| b.conn_state.state.connectivity.clone()) + .collect::>(); let oboxes = scheduler .oboxes .iter() .map(|b| b.conn_state.state.connectivity.clone()) .collect::>(); - (inbox, oboxes) + (inboxes, oboxes) } _ => return, }; drop(inner); - connectivity::idle_interrupted(inbox, oboxes); + connectivity::idle_interrupted(inboxes, oboxes); } /// Indicate that the network likely is lost. @@ -332,7 +336,8 @@ struct SchedBox { /// Job and connection scheduler. #[derive(Debug)] pub(crate) struct Scheduler { - inbox: SchedBox, + /// Inboxes, one per transport. + inboxes: Vec, /// Optional boxes -- mvbox. oboxes: Vec, smtp: SmtpConnectionState, @@ -858,39 +863,40 @@ impl Scheduler { let (ephemeral_interrupt_send, ephemeral_interrupt_recv) = channel::bounded(1); let (location_interrupt_send, location_interrupt_recv) = channel::bounded(1); + let mut inboxes = Vec::new(); let mut oboxes = Vec::new(); let mut start_recvs = Vec::new(); - let (transport_id, configured_login_param) = ConfiguredLoginParam::load(ctx) - .await? - .context("Not configured")?; - let (conn_state, inbox_handlers) = - ImapConnectionState::new(ctx, transport_id, configured_login_param.clone()).await?; - let (inbox_start_send, inbox_start_recv) = oneshot::channel(); - let handle = { - let ctx = ctx.clone(); - task::spawn(inbox_loop(ctx, inbox_start_send, inbox_handlers)) - }; - let inbox = SchedBox { - meaning: FolderMeaning::Inbox, - conn_state, - handle, - }; - start_recvs.push(inbox_start_recv); - - if ctx.should_watch_mvbox().await? { - let (conn_state, handlers) = - ImapConnectionState::new(ctx, transport_id, configured_login_param).await?; - let (start_send, start_recv) = oneshot::channel(); - let ctx = ctx.clone(); - let meaning = FolderMeaning::Mvbox; - let handle = task::spawn(simple_imap_loop(ctx, start_send, handlers, meaning)); - oboxes.push(SchedBox { - meaning, + for (transport_id, configured_login_param) in ConfiguredLoginParam::load_all(ctx).await? { + let (conn_state, inbox_handlers) = + ImapConnectionState::new(ctx, transport_id, configured_login_param.clone()).await?; + let (inbox_start_send, inbox_start_recv) = oneshot::channel(); + let handle = { + let ctx = ctx.clone(); + task::spawn(inbox_loop(ctx, inbox_start_send, inbox_handlers)) + }; + let inbox = SchedBox { + meaning: FolderMeaning::Inbox, conn_state, handle, - }); - start_recvs.push(start_recv); + }; + inboxes.push(inbox); + start_recvs.push(inbox_start_recv); + + if ctx.should_watch_mvbox().await? { + let (conn_state, handlers) = + ImapConnectionState::new(ctx, transport_id, configured_login_param).await?; + let (start_send, start_recv) = oneshot::channel(); + let ctx = ctx.clone(); + let meaning = FolderMeaning::Mvbox; + let handle = task::spawn(simple_imap_loop(ctx, start_send, handlers, meaning)); + oboxes.push(SchedBox { + meaning, + conn_state, + handle, + }); + start_recvs.push(start_recv); + } } let smtp_handle = { @@ -916,7 +922,7 @@ impl Scheduler { let recently_seen_loop = RecentlySeenLoop::new(ctx.clone()); let res = Self { - inbox, + inboxes, oboxes, smtp, smtp_handle, @@ -936,8 +942,8 @@ impl Scheduler { Ok(res) } - fn boxes(&self) -> iter::Chain, std::slice::Iter<'_, SchedBox>> { - once(&self.inbox).chain(self.oboxes.iter()) + fn boxes(&self) -> iter::Chain, std::slice::Iter<'_, SchedBox>> { + self.inboxes.iter().chain(self.oboxes.iter()) } fn maybe_network(&self) { @@ -955,7 +961,9 @@ impl Scheduler { } fn interrupt_inbox(&self) { - self.inbox.conn_state.interrupt(); + for b in &self.inboxes { + b.conn_state.interrupt(); + } } fn interrupt_oboxes(&self) { @@ -995,7 +1003,7 @@ impl Scheduler { let timeout_duration = std::time::Duration::from_secs(30); let tracker = TaskTracker::new(); - for b in once(self.inbox).chain(self.oboxes) { + for b in self.inboxes.into_iter().chain(self.oboxes.into_iter()) { let context = context.clone(); tracker.spawn(async move { tokio::time::timeout(timeout_duration, b.handle) diff --git a/src/scheduler/connectivity.rs b/src/scheduler/connectivity.rs index 16ec305121..589ff8d84b 100644 --- a/src/scheduler/connectivity.rs +++ b/src/scheduler/connectivity.rs @@ -201,19 +201,20 @@ impl ConnectivityStore { /// Set all folder states to InterruptingIdle in case they were `Idle` before. /// Called during `dc_maybe_network()` to make sure that `all_work_done()` /// returns false immediately after `dc_maybe_network()`. -pub(crate) fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec) { - let mut connectivity_lock = inbox.0.lock(); - // For the inbox, we also have to set the connectivity to InterruptingIdle if it was - // NotConfigured before: If all folders are NotConfigured, dc_get_connectivity() - // returns Connected. But after dc_maybe_network(), dc_get_connectivity() must not - // return Connected until DC is completely done with fetching folders; this also - // includes scan_folders() which happens on the inbox thread. - if *connectivity_lock == DetailedConnectivity::Idle - || *connectivity_lock == DetailedConnectivity::NotConfigured - { - *connectivity_lock = DetailedConnectivity::InterruptingIdle; +pub(crate) fn idle_interrupted(inboxes: Vec, oboxes: Vec) { + if let Some(inbox) = inboxes.first() { + let mut connectivity_lock = inbox.0.lock(); + // For the inbox, we also have to set the connectivity to InterruptingIdle if it was + // NotConfigured before: If all folders are NotConfigured, dc_get_connectivity() + // returns Connected. But after dc_maybe_network(), dc_get_connectivity() must not + // return Connected until DC is completely done with fetching folders; this also + // includes scan_folders() which happens on the inbox thread. + if *connectivity_lock == DetailedConnectivity::Idle + || *connectivity_lock == DetailedConnectivity::NotConfigured + { + *connectivity_lock = DetailedConnectivity::InterruptingIdle; + } } - drop(connectivity_lock); for state in oboxes { let mut connectivity_lock = state.0.lock(); From 3b6cae235091c11191081c571fbbc8473cc793e0 Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 3 Nov 2025 23:27:07 +0000 Subject: [PATCH 17/35] test that own vcard contains the current primary transport address --- deltachat-rpc-client/tests/test_multitransport.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index 8c59b60f90..f4ab8cff27 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -68,6 +68,8 @@ def test_change_address(acfactory) -> None: alice.stop_io() old_alice_addr = alice.get_config("configured_addr") + alice_vcard = alice.self_contact.make_vcard() + assert old_alice_addr in alice_vcard qr = acfactory.get_account_qr() alice.add_transport_from_qr(qr) new_alice_addr = alice.list_transports()[1]["addr"] @@ -76,6 +78,9 @@ def test_change_address(acfactory) -> None: # configured for any transport. alice.set_config("configured_addr", bob_addr) alice.set_config("configured_addr", new_alice_addr) + alice_vcard = alice.self_contact.make_vcard() + assert old_alice_addr not in alice_vcard + assert new_alice_addr in alice_vcard with pytest.raises(JsonRpcError): alice.delete_transport(new_alice_addr) alice.start_io() From 9edb503b479eba6f88cef533e81f5603c108b2da Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 3 Nov 2025 23:43:33 +0000 Subject: [PATCH 18/35] test: mvbox_move is disabled when first transport is set up --- .../tests/test_multitransport.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index f4ab8cff27..ccb10e6ce2 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -94,3 +94,27 @@ def test_change_address(acfactory) -> None: assert sender_addr1 != sender_addr2 assert sender_addr1 == old_alice_addr assert sender_addr2 == new_alice_addr + + +@pytest.mark.parametrize("is_chatmail", ["0", "1"]) +def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None: + """Test that mvbox_move is disabled by default even for non-chatmail accounts. + Disabling mvbox_move is required to be able to setup a second transport. + """ + account = acfactory.get_unconfigured_account() + + account.set_config("fix_is_chatmail", "1") + account.set_config("is_chatmail", is_chatmail) + + # The default value when the setting is unset is "1". + # This is not changed for compatibility with old databases + # imported from backups. + assert account.get_config("mvbox_move") == "1" + + qr = acfactory.get_account_qr() + account.add_transport_from_qr(qr) + + # Once the first transport is set up, + # mvbox_move is disabled. + assert account.get_config("mvbox_move") == "0" + assert account.get_config("is_chatmail") == is_chatmail From f92b255cfe1d3f5eb0d927beb8516606597c8fb8 Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 5 Nov 2025 19:38:48 +0000 Subject: [PATCH 19/35] test: wait until account is connected in resetup_account() --- deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py index c1a01598aa..add7d624b9 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py @@ -82,6 +82,7 @@ def resetup_account(self, ac: Account) -> Account: ac_clone = self.get_unconfigured_account() for transport in transports: ac_clone.add_or_update_transport(transport) + ac_clone.bring_online() return ac_clone def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat: From 96217ac2ea32e9355cb2a4a661c52e6bf6501b8c Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 5 Nov 2025 20:24:07 +0000 Subject: [PATCH 20/35] Delete imap and imap_sync rows when the transport is deleted --- src/configure.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/configure.rs b/src/configure.rs index 4bef113373..c5065c3824 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -220,7 +220,20 @@ impl Context { if current_addr == addr { bail!("Cannot delete current transport"); } - transaction.execute("DELETE FROM transports WHERE addr=?", (addr,))?; + let transport_id = transaction.query_row( + "DELETE FROM transports WHERE addr=? RETURNING id", + (addr,), + |row| { + let id: u32 = row.get(0)?; + Ok(id) + }, + )?; + transaction.execute("DELETE FROM imap WHERE transport_id=?", (transport_id,))?; + transaction.execute( + "DELETE FROM imap_sync WHERE transport_id=?", + (transport_id,), + )?; + Ok(()) }) .await?; From b0d661fc98ba9e8bea8447093f8e0dd89fdc1bb9 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 7 Nov 2025 05:59:23 +0000 Subject: [PATCH 21/35] Never create mvbox when configuring a new transport --- src/configure.rs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/configure.rs b/src/configure.rs index c5065c3824..317513d127 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -558,25 +558,10 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result { - let is_chatmail = imap_session.is_chatmail(); - ctx.set_config( - Config::IsChatmail, - Some(match is_chatmail { - false => "0", - true => "1", - }), - ) - .await?; - is_chatmail - } - true => ctx.get_config_bool(Config::IsChatmail).await?, - }; ctx.sql.set_raw_config("mvbox_move", Some("0")).await?; ctx.sql.set_raw_config("only_fetch_mvbox", None).await?; - let create_mvbox = !is_chatmail; + let create_mvbox = false; imap.configure_folders(ctx, &mut imap_session, create_mvbox) .await?; From e12650f8cc95d7431b6733afdcdb55c751fdbbb0 Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 3 Nov 2025 23:59:28 +0000 Subject: [PATCH 22/35] store transport ID for IMAP session --- src/imap.rs | 70 +++++++++++++++++++++++++++------------ src/imap/imap_tests.rs | 24 +++++++++----- src/imap/select_folder.rs | 17 +++++----- src/imap/session.rs | 9 +++++ 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/src/imap.rs b/src/imap.rs index ad39c14945..e9ccc95f21 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -409,9 +409,19 @@ impl Imap { }) .await .context("Failed to enable IMAP compression")?; - Session::new(compressed_session, capabilities, resync_request_sender) + Session::new( + compressed_session, + capabilities, + resync_request_sender, + self.transport_id, + ) } else { - Session::new(session, capabilities, resync_request_sender) + Session::new( + session, + capabilities, + resync_request_sender, + self.transport_id, + ) }; // Store server ID in the context to display in account info. @@ -590,8 +600,9 @@ impl Imap { folder: &str, folder_meaning: FolderMeaning, ) -> Result<(usize, bool)> { - let uid_validity = get_uidvalidity(context, folder).await?; - let old_uid_next = get_uid_next(context, folder).await?; + let transport_id = self.transport_id; + let uid_validity = get_uidvalidity(context, transport_id, folder).await?; + let old_uid_next = get_uid_next(context, transport_id, folder).await?; info!( context, "fetch_new_msg_batch({folder}): UIDVALIDITY={uid_validity}, UIDNEXT={old_uid_next}." @@ -782,7 +793,7 @@ impl Imap { prefetch_uid_next < mailbox_uid_next }; if new_uid_next > old_uid_next { - set_uid_next(context, folder, new_uid_next).await?; + set_uid_next(context, self.transport_id, folder, new_uid_next).await?; } info!(context, "{} mails read from \"{}\".", read_cnt, folder); @@ -862,6 +873,7 @@ impl Session { let folder_exists = self .select_with_uidvalidity(context, folder, create) .await?; + let transport_id = self.transport_id(); if folder_exists { let mut list = self .uid_fetch("1:*", RFC724MID_UID) @@ -894,13 +906,12 @@ impl Session { msgs.len(), ); - uid_validity = get_uidvalidity(context, folder).await?; + uid_validity = get_uidvalidity(context, transport_id, folder).await?; } else { warn!(context, "resync_folder_uids: No folder {folder}."); uid_validity = 0; } - let transport_id = 1; // FIXME // Write collected UIDs to SQLite database. context .sql @@ -1237,11 +1248,12 @@ impl Session { return Ok(()); } + let transport_id = self.transport_id(); let mut updated_chat_ids = BTreeSet::new(); - let uid_validity = get_uidvalidity(context, folder) + let uid_validity = get_uidvalidity(context, transport_id, folder) .await .with_context(|| format!("failed to get UID validity for folder {folder}"))?; - let mut highest_modseq = get_modseq(context, folder) + let mut highest_modseq = get_modseq(context, transport_id, folder) .await .with_context(|| format!("failed to get MODSEQ for folder {folder}"))?; let mut list = self @@ -1292,7 +1304,7 @@ impl Session { self.new_mail = true; } - set_modseq(context, folder, highest_modseq) + set_modseq(context, transport_id, folder, highest_modseq) .await .with_context(|| format!("failed to set MODSEQ for folder {folder}"))?; if !updated_chat_ids.is_empty() { @@ -2422,8 +2434,12 @@ pub(crate) async fn markseen_on_imap_table(context: &Context, message_id: &str) /// uid_next is the next unique identifier value from the last time we fetched a folder /// See /// This function is used to update our uid_next after fetching messages. -pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32) -> Result<()> { - let transport_id = 1; // FIXME +pub(crate) async fn set_uid_next( + context: &Context, + transport_id: u32, + folder: &str, + uid_next: u32, +) -> Result<()> { context .sql .execute( @@ -2440,20 +2456,23 @@ pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32) /// This method returns the uid_next from the last time we fetched messages. /// We can compare this to the current uid_next to find out whether there are new messages /// and fetch from this value on to get all new messages. -async fn get_uid_next(context: &Context, folder: &str) -> Result { +async fn get_uid_next(context: &Context, transport_id: u32, folder: &str) -> Result { Ok(context .sql - .query_get_value("SELECT uid_next FROM imap_sync WHERE folder=?;", (folder,)) + .query_get_value( + "SELECT uid_next FROM imap_sync WHERE transport_id=? AND folder=?", + (transport_id, folder), + ) .await? .unwrap_or(0)) } pub(crate) async fn set_uidvalidity( context: &Context, + transport_id: u32, folder: &str, uidvalidity: u32, ) -> Result<()> { - let transport_id = 1; context .sql .execute( @@ -2465,19 +2484,23 @@ pub(crate) async fn set_uidvalidity( Ok(()) } -async fn get_uidvalidity(context: &Context, folder: &str) -> Result { +async fn get_uidvalidity(context: &Context, transport_id: u32, folder: &str) -> Result { Ok(context .sql .query_get_value( - "SELECT uidvalidity FROM imap_sync WHERE folder=?;", - (folder,), + "SELECT uidvalidity FROM imap_sync WHERE transport_id=? AND folder=?", + (transport_id, folder), ) .await? .unwrap_or(0)) } -pub(crate) async fn set_modseq(context: &Context, folder: &str, modseq: u64) -> Result<()> { - let transport_id = 1; // FIXME +pub(crate) async fn set_modseq( + context: &Context, + transport_id: u32, + folder: &str, + modseq: u64, +) -> Result<()> { context .sql .execute( @@ -2489,10 +2512,13 @@ pub(crate) async fn set_modseq(context: &Context, folder: &str, modseq: u64) -> Ok(()) } -async fn get_modseq(context: &Context, folder: &str) -> Result { +async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Result { Ok(context .sql - .query_get_value("SELECT modseq FROM imap_sync WHERE folder=?;", (folder,)) + .query_get_value( + "SELECT modseq FROM imap_sync WHERE transport_id=? AND folder=?", + (transport_id, folder), + ) .await? .unwrap_or(0)) } diff --git a/src/imap/imap_tests.rs b/src/imap/imap_tests.rs index 304b9b5e20..88d0ac0570 100644 --- a/src/imap/imap_tests.rs +++ b/src/imap/imap_tests.rs @@ -11,17 +11,23 @@ fn test_get_folder_meaning_by_name() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_set_uid_next_validity() { let t = TestContext::new_alice().await; - assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0); - assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0); + assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 0); + assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 0); - set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap(); - assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7); - assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0); + set_uidvalidity(&t.ctx, 1, "Inbox", 7).await.unwrap(); + assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 7); + assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 0); - set_uid_next(&t.ctx, "Inbox", 5).await.unwrap(); - set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap(); - assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5); - assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6); + // For another transport there is still no UIDVALIDITY set. + assert_eq!(get_uidvalidity(&t.ctx, 2, "Inbox").await.unwrap(), 0); + + set_uid_next(&t.ctx, 1, "Inbox", 5).await.unwrap(); + set_uidvalidity(&t.ctx, 1, "Inbox", 6).await.unwrap(); + assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 5); + assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 6); + + assert_eq!(get_uid_next(&t.ctx, 2, "Inbox").await.unwrap(), 0); + assert_eq!(get_uidvalidity(&t.ctx, 2, "Inbox").await.unwrap(), 0); } #[test] diff --git a/src/imap/select_folder.rs b/src/imap/select_folder.rs index 2a42b13ff7..81e98753ed 100644 --- a/src/imap/select_folder.rs +++ b/src/imap/select_folder.rs @@ -146,15 +146,16 @@ impl ImapSession { }, } }; + let transport_id = self.transport_id(); let mailbox = self .selected_mailbox .as_mut() .with_context(|| format!("No mailbox selected, folder: {folder:?}"))?; - let old_uid_validity = get_uidvalidity(context, folder) + let old_uid_validity = get_uidvalidity(context, transport_id, folder) .await .with_context(|| format!("Failed to get old UID validity for folder {folder:?}"))?; - let old_uid_next = get_uid_next(context, folder) + let old_uid_next = get_uid_next(context, transport_id, folder) .await .with_context(|| format!("Failed to get old UID NEXT for folder {folder:?}"))?; @@ -205,8 +206,8 @@ impl ImapSession { context, "The server illegally decreased the uid_next of folder {folder:?} from {old_uid_next} to {new_uid_next} without changing validity ({new_uid_validity}), resyncing UIDs...", ); - set_uid_next(context, folder, new_uid_next).await?; self.resync_request_sender.try_send(()).ok(); + set_uid_next(context, transport_id, folder, new_uid_next).await?; } // If UIDNEXT changed, there are new emails. @@ -224,21 +225,21 @@ impl ImapSession { } // UIDVALIDITY is modified, reset highest seen MODSEQ. - set_modseq(context, folder, 0).await?; + set_modseq(context, transport_id, folder, 0).await?; // ============== uid_validity has changed or is being set the first time. ============== let new_uid_next = new_uid_next.unwrap_or_default(); - set_uid_next(context, folder, new_uid_next).await?; - set_uidvalidity(context, folder, new_uid_validity).await?; + set_uid_next(context, transport_id, folder, new_uid_next).await?; + set_uidvalidity(context, transport_id, folder, new_uid_validity).await?; self.new_mail = true; // Collect garbage entries in `imap` table. context .sql .execute( - "DELETE FROM imap WHERE folder=? AND uidvalidity!=?", - (&folder, new_uid_validity), + "DELETE FROM imap WHERE transport_id=? AND folder=? AND uidvalidity!=?", + (transport_id, &folder, new_uid_validity), ) .await?; diff --git a/src/imap/session.rs b/src/imap/session.rs index 8cf0a17de0..0da1d7936f 100644 --- a/src/imap/session.rs +++ b/src/imap/session.rs @@ -30,6 +30,8 @@ const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIE #[derive(Debug)] pub(crate) struct Session { + transport_id: u32, + pub(super) inner: ImapSession>, pub capabilities: Capabilities, @@ -71,8 +73,10 @@ impl Session { inner: ImapSession>, capabilities: Capabilities, resync_request_sender: async_channel::Sender<()>, + transport_id: u32, ) -> Self { Self { + transport_id, inner, capabilities, selected_folder: None, @@ -84,6 +88,11 @@ impl Session { } } + /// Returns ID of the transport for which this session was created. + pub(crate) fn transport_id(&self) -> u32 { + self.transport_id + } + pub fn can_idle(&self) -> bool { self.capabilities.can_idle } From e39a655141a0b3574e3bd3ec9390c83f412de537 Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 5 Nov 2025 21:09:10 +0000 Subject: [PATCH 23/35] log transport id on UID validity change --- deltachat-rpc-client/tests/test_folders.py | 5 ++++- src/imap/select_folder.rs | 7 +------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/deltachat-rpc-client/tests/test_folders.py b/deltachat-rpc-client/tests/test_folders.py index f2b84d7ebc..7ee80b202d 100644 --- a/deltachat-rpc-client/tests/test_folders.py +++ b/deltachat-rpc-client/tests/test_folders.py @@ -143,7 +143,10 @@ def test_delete_deltachat_folder(acfactory, direct_imap): # Wait until new folder is created and UIDVALIDITY is updated. while True: event = ac1.wait_for_event() - if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg: + if ( + event.kind == EventType.INFO + and "UID validity for folder DeltaChat and transport 1 changed from " in event.msg + ): break ac2 = acfactory.get_online_account() diff --git a/src/imap/select_folder.rs b/src/imap/select_folder.rs index 81e98753ed..f59dc224ce 100644 --- a/src/imap/select_folder.rs +++ b/src/imap/select_folder.rs @@ -248,12 +248,7 @@ impl ImapSession { } info!( context, - "uid/validity change folder {}: new {}/{} previous {}/{}.", - folder, - new_uid_next, - new_uid_validity, - old_uid_next, - old_uid_validity, + "UID validity for folder {folder} and transport {transport_id} changed from {old_uid_validity}/{old_uid_next} to {new_uid_validity}/{new_uid_next}.", ); Ok(true) } From 0b3f8682e9db9d25f0e7c9283dab6f56d6db5f76 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 7 Nov 2025 05:59:23 +0000 Subject: [PATCH 24/35] transport_id should be known when selecting folders --- src/imap/select_folder.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/imap/select_folder.rs b/src/imap/select_folder.rs index f59dc224ce..fcf61dbcac 100644 --- a/src/imap/select_folder.rs +++ b/src/imap/select_folder.rs @@ -147,6 +147,11 @@ impl ImapSession { } }; let transport_id = self.transport_id(); + + // Folders should not be selected when transport_id is not assigned yet + // because we cannot save UID validity then. + debug_assert!(transport_id > 0); + let mailbox = self .selected_mailbox .as_mut() From d03ee98d2b68b57503aac8d492d12eed6b5d2727 Mon Sep 17 00:00:00 2001 From: link2xt Date: Sun, 9 Nov 2025 22:25:08 +0000 Subject: [PATCH 25/35] Do not try to get UID validity of inbox during configuration --- src/configure.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/configure.rs b/src/configure.rs index 317513d127..91d93b235f 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -565,12 +565,6 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result Date: Sun, 9 Nov 2025 23:15:58 +0000 Subject: [PATCH 26/35] bring accounts online in wait_next_messages test --- deltachat-rpc-client/tests/test_something.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index 64154f5279..6db131f6e5 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -467,7 +467,7 @@ def track(e): def test_wait_next_messages(acfactory) -> None: - alice = acfactory.new_configured_account() + alice = acfactory.get_online_account() # Create a bot account so it does not receive device messages in the beginning. addr, password = acfactory.get_credentials() @@ -475,6 +475,7 @@ def test_wait_next_messages(acfactory) -> None: bot.set_config("bot", "1") bot.add_or_update_transport({"addr": addr, "password": password}) assert bot.is_configured() + bot.bring_online() # There are no old messages and the call returns immediately. assert not bot.wait_next_messages() From 67b194b295b3f2a4b4f4908f2406fe626e307f6c Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 13 Nov 2025 11:00:04 +0000 Subject: [PATCH 27/35] test: skip flaky test_echo_quit_plugin --- python/examples/test_examples.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/examples/test_examples.py b/python/examples/test_examples.py index 58fac9c65d..26a1e91627 100644 --- a/python/examples/test_examples.py +++ b/python/examples/test_examples.py @@ -14,6 +14,7 @@ def datadir(): return None +@pytest.mark.skip("The test is flaky in CI and crashes the interpreter as of 2025-11-12") def test_echo_quit_plugin(acfactory, lp): lp.sec("creating one echo_and_quit bot") botproc = acfactory.run_bot_process(echo_and_quit) From 19f2a67a0b6069a692b800dbc5989de1d7966ea4 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 13 Nov 2025 11:23:01 +0000 Subject: [PATCH 28/35] fix: clean up SMTP queue when the transport is changed --- src/config.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/config.rs b/src/config.rs index 83099bdf33..e5cf6be747 100644 --- a/src/config.rs +++ b/src/config.rs @@ -836,6 +836,13 @@ impl Context { "UPDATE config SET value=? WHERE keyname='configured_addr'", (value,), )?; + + // Clean up SMTP queue. + // + // The messages in the queue have a different + // From address so we cannot send them over + // the new SMTP transport. + transaction.execute("DELETE FROM smtp", ())?; Ok(()) }) .await?; From 6e05d9380d6b7dccf0fb64c52a0a0675edacec65 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 13 Nov 2025 11:25:50 +0000 Subject: [PATCH 29/35] require show_emails=2 for multi-transport --- .../tests/test_multitransport.py | 17 +++++++++++++++++ src/config.rs | 7 ++++++- src/configure.rs | 3 +++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index ccb10e6ce2..caac2dfbe1 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -36,6 +36,9 @@ def test_add_second_address(acfactory) -> None: with pytest.raises(JsonRpcError): account.set_config(option, "1") + with pytest.raises(JsonRpcError): + account.set_config("show_emails", "0") + @pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"]) def test_no_second_transport_with_mvbox(acfactory, key) -> None: @@ -53,6 +56,20 @@ def test_no_second_transport_with_mvbox(acfactory, key) -> None: account.add_transport_from_qr(qr) +def test_no_second_transport_without_classic_emails(acfactory) -> None: + """Test that second transport cannot be configured if classic emails are not fetched.""" + account = acfactory.new_configured_account() + assert len(account.list_transports()) == 1 + + assert account.get_config("show_emails") == "2" + + qr = acfactory.get_account_qr() + account.set_config("show_emails", "0") + + with pytest.raises(JsonRpcError): + account.add_transport_from_qr(qr) + + def test_change_address(acfactory) -> None: """Test Alice configuring a second transport and setting it as a primary one.""" alice, bob = acfactory.get_online_accounts(2) diff --git a/src/config.rs b/src/config.rs index e5cf6be747..8399e0011a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -717,7 +717,12 @@ impl Context { Self::check_config(key, value)?; let n_transports = self.count_transports().await?; - if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) { + if n_transports > 1 + && matches!( + key, + Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails + ) + { bail!("Cannot reconfigure {key} when multiple transports are configured"); } diff --git a/src/configure.rs b/src/configure.rs index 91d93b235f..1793ba6398 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -251,6 +251,9 @@ impl Context { if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") { bail!("Cannot use multi-transport with only_fetch_mvbox enabled."); } + if self.get_config(Config::ShowEmails).await?.as_deref() != Some("2") { + bail!("Cannot use multi-transport with disabled fetching of classic emails."); + } } let provider = configure(self, param).await?; From 28d644a4598234be638dda67e8fa966d6f385e11 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 13 Nov 2025 12:28:01 +0000 Subject: [PATCH 30/35] do not allow to unset configured_addr --- src/config.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8399e0011a..05f8bcb344 100644 --- a/src/config.rs +++ b/src/config.rs @@ -811,24 +811,26 @@ impl Context { .await?; } Config::ConfiguredAddr => { + let Some(addr) = value else { + bail!("Cannot unset configured_addr"); + }; + if !self.is_configured().await? { - if let Some(addr) = value { - info!( - self, - "Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!" - ); - ConfiguredLoginParam::from_json(&format!( - r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"# - ))? - .save_to_transports_table(self, &EnteredLoginParam::default()) - .await?; - } + info!( + self, + "Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!" + ); + ConfiguredLoginParam::from_json(&format!( + r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"# + ))? + .save_to_transports_table(self, &EnteredLoginParam::default()) + .await?; } self.sql .transaction(|transaction| { if transaction.query_row( "SELECT COUNT(*) FROM transports WHERE addr=?", - (value,), + (addr,), |row| { let res: i64 = row.get(0)?; Ok(res) @@ -839,7 +841,7 @@ impl Context { } transaction.execute( "UPDATE config SET value=? WHERE keyname='configured_addr'", - (value,), + (addr,), )?; // Clean up SMTP queue. From e1ce996f37f72c63c4c8314561662e4453591dc5 Mon Sep 17 00:00:00 2001 From: link2xt Date: Thu, 13 Nov 2025 13:22:20 +0000 Subject: [PATCH 31/35] test: wait for inbox idle in typescript tests --- deltachat-jsonrpc/typescript/test/online.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deltachat-jsonrpc/typescript/test/online.ts b/deltachat-jsonrpc/typescript/test/online.ts index c633fc2916..60bd1ddf53 100644 --- a/deltachat-jsonrpc/typescript/test/online.ts +++ b/deltachat-jsonrpc/typescript/test/online.ts @@ -64,6 +64,7 @@ describe("online tests", function () { await dc.rpc.setConfig(accountId1, "addr", account1.email); await dc.rpc.setConfig(accountId1, "mail_pw", account1.password); await dc.rpc.configure(accountId1); + await waitForEvent(dc, "ImapInboxIdle", accountId1); accountId2 = await dc.rpc.addAccount(); await dc.rpc.batchSetConfig(accountId2, { @@ -71,6 +72,7 @@ describe("online tests", function () { mail_pw: account2.password, }); await dc.rpc.configure(accountId2); + await waitForEvent(dc, "ImapInboxIdle", accountId2); accountsConfigured = true; }); From 14cc5977192b491613cddb819448dc843872ab0b Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 19 Nov 2025 23:16:26 +0000 Subject: [PATCH 32/35] fix: remove configured_addr from cache after setting it --- deltachat-rpc-client/tests/test_multitransport.py | 6 ++++++ src/config.rs | 1 + 2 files changed, 7 insertions(+) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index caac2dfbe1..791155aa29 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -94,7 +94,13 @@ def test_change_address(acfactory) -> None: # Cannot use the address that is not # configured for any transport. alice.set_config("configured_addr", bob_addr) + + # Load old address so it is cached. + assert alice.get_config("configured_addr") == old_alice_addr alice.set_config("configured_addr", new_alice_addr) + # Make sure that setting `configured_addr` invalidated the cache. + assert alice.get_config("configured_addr") == new_alice_addr + alice_vcard = alice.self_contact.make_vcard() assert old_alice_addr not in alice_vcard assert new_alice_addr in alice_vcard diff --git a/src/config.rs b/src/config.rs index 05f8bcb344..6592e2d9bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -853,6 +853,7 @@ impl Context { Ok(()) }) .await?; + self.sql.uncache_raw_config("configured_addr").await; } _ => { self.sql.set_raw_config(key.as_ref(), value).await?; From 7fd6aa2e848ddaf17a63d040dbec9155be2ee2fe Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 19 Nov 2025 23:37:48 +0000 Subject: [PATCH 33/35] docs: document ConfiguredLoginParam::load return value --- src/transport.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/transport.rs b/src/transport.rs index 8d53734f59..4765a9befd 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -240,6 +240,8 @@ impl fmt::Display for ConfiguredLoginParam { impl ConfiguredLoginParam { /// Load configured account settings from the database. /// + /// Returns transport ID and configured parameters + /// of the current primary transport. /// Returns `None` if account is not configured. pub(crate) async fn load(context: &Context) -> Result> { let Some(self_addr) = context.get_config(Config::ConfiguredAddr).await? else { From a4a14f87b9458cf6b8bc0bb08f0cdaf2febbcd1e Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 19 Nov 2025 23:39:51 +0000 Subject: [PATCH 34/35] docs: ConfiguredLoginParam::load_all return value --- src/transport.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/transport.rs b/src/transport.rs index 4765a9befd..7535f40a57 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -267,6 +267,9 @@ impl ConfiguredLoginParam { } /// Loads configured login parameters for all transports. + /// + /// Returns a vector of all transport IDs + /// paired with the configured parameters for the transports. pub(crate) async fn load_all(context: &Context) -> Result> { context .sql From f4e5042f44818c40e027a6b08f6bd40009ddda2c Mon Sep 17 00:00:00 2001 From: link2xt Date: Wed, 19 Nov 2025 23:41:30 +0000 Subject: [PATCH 35/35] tweak log message about UID validity --- deltachat-rpc-client/tests/test_folders.py | 2 +- src/imap/select_folder.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deltachat-rpc-client/tests/test_folders.py b/deltachat-rpc-client/tests/test_folders.py index 7ee80b202d..1759a4e999 100644 --- a/deltachat-rpc-client/tests/test_folders.py +++ b/deltachat-rpc-client/tests/test_folders.py @@ -145,7 +145,7 @@ def test_delete_deltachat_folder(acfactory, direct_imap): event = ac1.wait_for_event() if ( event.kind == EventType.INFO - and "UID validity for folder DeltaChat and transport 1 changed from " in event.msg + and "transport 1: UID validity for folder DeltaChat changed from " in event.msg ): break diff --git a/src/imap/select_folder.rs b/src/imap/select_folder.rs index fcf61dbcac..6f55dba789 100644 --- a/src/imap/select_folder.rs +++ b/src/imap/select_folder.rs @@ -253,7 +253,7 @@ impl ImapSession { } info!( context, - "UID validity for folder {folder} and transport {transport_id} changed from {old_uid_validity}/{old_uid_next} to {new_uid_validity}/{new_uid_next}.", + "transport {transport_id}: UID validity for folder {folder} changed from {old_uid_validity}/{old_uid_next} to {new_uid_validity}/{new_uid_next}.", ); Ok(true) }