Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion deltachat-rpc-client/tests/test_folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ 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 "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
break

ac2 = acfactory.get_online_account()
Expand Down
158 changes: 158 additions & 0 deletions deltachat-rpc-client/tests/test_multitransport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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)

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


def test_reconfigure_transport(acfactory) -> None:
"""Test that reconfiguring the transport works
even if settings not supported for multi-transport
like mvbox_move are enabled."""
account = acfactory.get_online_account()
account.set_config("mvbox_move", "1")

[transport] = account.list_transports()
account.add_or_update_transport(transport)

# Reconfiguring the transport should not reset
# the settings as if when configuring the first transport.
assert account.get_config("mvbox_move") == "1"
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
54 changes: 49 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}

Expand Down Expand Up @@ -713,6 +716,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 @@ -798,10 +811,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 @@ -812,6 +826,36 @@ 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 and IMAP APPEND 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", ())?;
Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems that messages should be re-sent over the new transport. Maybe just calling chat::resend_msgs() works, it doesn't require that messages should be from the same chat

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I moved this as a TODO item to the tracking issue. Ideally we need to add another column for SMTP queue so each transport has its queue and messages are sent over secondary transports as well, but not in this PR.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Then the messages should get OutFailed, otherwise they'll hang forever in OutPending i guess

Copy link
Collaborator Author

@link2xt link2xt Nov 20, 2025

Choose a reason for hiding this comment

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

I think it should be fixed for real by not removing them from the queue before multi-transport is marked as non-experimental. Some messages are not visible, so dropping them is a temporary solution anyway. For now it will indeed hang in the "pending" state forever (or until the user resends the message manually).

transaction.execute("DELETE FROM imap_send", ())?;

Ok(())
})
.await?;
self.sql.uncache_raw_config("configured_addr").await;
}
_ => {
self.sql.set_raw_config(key.as_ref(), value).await?;
Expand Down
Loading
Loading