Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a0f8c49
feat: restart I/O when setting configured_addr
link2xt Nov 5, 2025
4d4d939
refactor: return transport ID from ConfiguredLoginParam::load()
link2xt Nov 1, 2025
9f7162a
feat: allow adding second transport
link2xt Oct 24, 2025
8e15a94
feat: allow deleting transports
link2xt Oct 25, 2025
532d6f2
feat: allow to set another transport as primary
link2xt Oct 26, 2025
12dc8d2
feat: add transport column to imap table
link2xt Oct 31, 2025
001a8a2
feat: add transport column to imap_sync table
link2xt Oct 31, 2025
12cdbec
api: add count_transports()
link2xt Oct 31, 2025
81d2d52
feat: require mvbox_move and only_fetch_mvbox to be disabled for mult…
link2xt Oct 31, 2025
45bb10a
load transport ID with ConfiguredLoginParam
link2xt Nov 1, 2025
08a9f58
test: test that second transport cannot be set up if mvbox is used
link2xt Nov 1, 2025
abe3667
test: current transport cannot be deleted after changing to it
link2xt Nov 2, 2025
a5ecc4a
fix: do not allow to set ConfiguredAddr to arbitrary values
link2xt Nov 2, 2025
8643a82
ConfiguredLoginParam::load_all()
link2xt Nov 3, 2025
8ceb42b
Imap::new() accepting login params
link2xt Nov 3, 2025
d74fa6d
start IMAP loops for all transports
link2xt Nov 3, 2025
61fce7c
test that own vcard contains the current primary transport address
link2xt Nov 3, 2025
cb870d4
test: mvbox_move is disabled when first transport is set up
link2xt Nov 3, 2025
b507da7
test: wait until account is connected in resetup_account()
link2xt Nov 5, 2025
924ed28
Delete imap and imap_sync rows when the transport is deleted
link2xt Nov 5, 2025
ecfceff
Never create mvbox when configuring a new transport
link2xt Nov 7, 2025
3ed6484
store transport ID for IMAP session
link2xt Nov 3, 2025
bf20490
log transport id on UID validity change
link2xt Nov 5, 2025
4a0dced
transport_id should be known when selecting folders
link2xt Nov 7, 2025
06cdde4
Do not try to get UID validity of inbox during configuration
link2xt Nov 9, 2025
23bdbab
bring accounts online in wait_next_messages test
link2xt Nov 9, 2025
f746f15
test: skip flaky test_echo_quit_plugin
link2xt Nov 13, 2025
52c8673
fix: clean up SMTP queue when the transport is changed
link2xt Nov 13, 2025
68c8f7c
require show_emails=2 for multi-transport
link2xt Nov 13, 2025
da83ae7
do not allow to unset configured_addr
link2xt Nov 13, 2025
c97938e
test: wait for inbox idle in typescript tests
link2xt Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deltachat-jsonrpc/typescript/test/online.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,15 @@ 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, {
addr: account2.email,
mail_pw: account2.password,
});
await dc.rpc.configure(accountId2);
await waitForEvent(dc, "ImapInboxIdle", accountId2);
accountsConfigured = true;
});

Expand Down
4 changes: 4 additions & 0 deletions deltachat-rpc-client/src/deltachat_rpc_client/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
10 changes: 8 additions & 2 deletions deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,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:
Expand Down
5 changes: 4 additions & 1 deletion deltachat-rpc-client/tests/test_folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
137 changes: 137 additions & 0 deletions deltachat-rpc-client/tests/test_multitransport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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

# 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

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

# 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")

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:
"""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_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)

bob_addr = bob.get_config("configured_addr")
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")
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"]
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)
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()

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


@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
3 changes: 2 additions & 1 deletion deltachat-rpc-client/tests/test_something.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,14 +467,15 @@ 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()
bot = acfactory.get_unconfigured_account()
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()
Expand Down
1 change: 1 addition & 0 deletions python/examples/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 46 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,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
)
}
}

Expand Down Expand Up @@ -706,6 +709,16 @@ 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 | Config::ShowEmails
)
{
bail!("Cannot reconfigure {key} when multiple transports are configured");
}

let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
Expand Down Expand Up @@ -791,10 +804,11 @@ impl Context {
.await?;
}
Config::ConfiguredAddr => {
if self.is_configured().await? {
bail!("Cannot change ConfiguredAddr");
}
if let Some(addr) = value {
let Some(addr) = value else {
bail!("Cannot unset configured_addr");
};

if !self.is_configured().await? {
info!(
self,
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
Expand All @@ -805,6 +819,33 @@ impl Context {
.save_to_transports_table(self, &EnteredLoginParam::default())
.await?;
}
self.sql
.transaction(|transaction| {
if transaction.query_row(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(addr,),
|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'",
(addr,),
)?;

// 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", ())?;
Comment on lines +840 to +845
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The SMTP queue is cleared when ConfiguredAddr is changed (line 843), but this happens even if the new address belongs to the same transport. Consider only clearing the SMTP queue if the transport_id actually changes, to avoid unnecessarily dropping outgoing messages when the address is temporarily switched back and forth between addresses of the same transport.

Copilot uses AI. Check for mistakes.
Ok(())
})
.await?;
}
_ => {
self.sql.set_raw_config(key.as_ref(), value).await?;
Expand Down
Loading
Loading