Skip to content

Commit d4e183d

Browse files
committed
feat: allow adding second transport
1 parent 57aadfb commit d4e183d

File tree

20 files changed

+582
-213
lines changed

20 files changed

+582
-213
lines changed

deltachat-jsonrpc/typescript/test/online.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ describe("online tests", function () {
6464
await dc.rpc.setConfig(accountId1, "addr", account1.email);
6565
await dc.rpc.setConfig(accountId1, "mail_pw", account1.password);
6666
await dc.rpc.configure(accountId1);
67+
await waitForEvent(dc, "ImapInboxIdle", accountId1);
6768

6869
accountId2 = await dc.rpc.addAccount();
6970
await dc.rpc.batchSetConfig(accountId2, {
7071
addr: account2.email,
7172
mail_pw: account2.password,
7273
});
7374
await dc.rpc.configure(accountId2);
75+
await waitForEvent(dc, "ImapInboxIdle", accountId2);
7476
accountsConfigured = true;
7577
});
7678

deltachat-rpc-client/src/deltachat_rpc_client/account.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ def add_transport_from_qr(self, qr: str):
130130
"""Add a new transport using a QR code."""
131131
yield self._rpc.add_transport_from_qr.future(self.id, qr)
132132

133+
def delete_transport(self, addr: str):
134+
"""Delete a transport."""
135+
self._rpc.delete_transport(self.id, addr)
136+
133137
@futuremethod
134138
def list_transports(self):
135139
"""Return the list of all email accounts that are used as a transport in the current profile."""

deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,17 @@ def get_credentials(self) -> (str, str):
4040
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
4141
return f"{username}@{domain}", f"{username}${username}"
4242

43+
def get_account_qr(self):
44+
"""Return "dcaccount:" QR code for testing chatmail relay."""
45+
domain = os.getenv("CHATMAIL_DOMAIN")
46+
return f"dcaccount:{domain}"
47+
4348
@futuremethod
4449
def new_configured_account(self):
4550
"""Create a new configured account."""
4651
account = self.get_unconfigured_account()
47-
domain = os.getenv("CHATMAIL_DOMAIN")
48-
yield account.add_transport_from_qr.future(f"dcaccount:{domain}")
52+
qr = self.get_account_qr()
53+
yield account.add_transport_from_qr.future(qr)
4954

5055
assert account.is_configured()
5156
return account
@@ -77,6 +82,7 @@ def resetup_account(self, ac: Account) -> Account:
7782
ac_clone = self.get_unconfigured_account()
7883
for transport in transports:
7984
ac_clone.add_or_update_transport(transport)
85+
ac_clone.bring_online()
8086
return ac_clone
8187

8288
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:

deltachat-rpc-client/tests/test_folders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def test_delete_deltachat_folder(acfactory, direct_imap):
143143
# Wait until new folder is created and UIDVALIDITY is updated.
144144
while True:
145145
event = ac1.wait_for_event()
146-
if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg:
146+
if event.kind == EventType.INFO and "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
147147
break
148148

149149
ac2 = acfactory.get_online_account()
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import pytest
2+
3+
from deltachat_rpc_client.rpc import JsonRpcError
4+
5+
6+
def test_add_second_address(acfactory) -> None:
7+
account = acfactory.new_configured_account()
8+
assert len(account.list_transports()) == 1
9+
10+
# When the first transport is created,
11+
# mvbox_move and only_fetch_mvbox should be disabled.
12+
assert account.get_config("mvbox_move") == "0"
13+
assert account.get_config("only_fetch_mvbox") == "0"
14+
assert account.get_config("show_emails") == "2"
15+
16+
qr = acfactory.get_account_qr()
17+
account.add_transport_from_qr(qr)
18+
assert len(account.list_transports()) == 2
19+
20+
account.add_transport_from_qr(qr)
21+
assert len(account.list_transports()) == 3
22+
23+
first_addr = account.list_transports()[0]["addr"]
24+
second_addr = account.list_transports()[1]["addr"]
25+
26+
# Cannot delete the first address.
27+
with pytest.raises(JsonRpcError):
28+
account.delete_transport(first_addr)
29+
30+
account.delete_transport(second_addr)
31+
assert len(account.list_transports()) == 2
32+
33+
# Enabling mvbox_move or only_fetch_mvbox
34+
# is not allowed when multi-transport is enabled.
35+
for option in ["mvbox_move", "only_fetch_mvbox"]:
36+
with pytest.raises(JsonRpcError):
37+
account.set_config(option, "1")
38+
39+
with pytest.raises(JsonRpcError):
40+
account.set_config("show_emails", "0")
41+
42+
43+
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
44+
def test_no_second_transport_with_mvbox(acfactory, key) -> None:
45+
"""Test that second transport cannot be configured if mvbox is used."""
46+
account = acfactory.new_configured_account()
47+
assert len(account.list_transports()) == 1
48+
49+
assert account.get_config("mvbox_move") == "0"
50+
assert account.get_config("only_fetch_mvbox") == "0"
51+
52+
qr = acfactory.get_account_qr()
53+
account.set_config(key, "1")
54+
55+
with pytest.raises(JsonRpcError):
56+
account.add_transport_from_qr(qr)
57+
58+
59+
def test_no_second_transport_without_classic_emails(acfactory) -> None:
60+
"""Test that second transport cannot be configured if classic emails are not fetched."""
61+
account = acfactory.new_configured_account()
62+
assert len(account.list_transports()) == 1
63+
64+
assert account.get_config("show_emails") == "2"
65+
66+
qr = acfactory.get_account_qr()
67+
account.set_config("show_emails", "0")
68+
69+
with pytest.raises(JsonRpcError):
70+
account.add_transport_from_qr(qr)
71+
72+
73+
def test_change_address(acfactory) -> None:
74+
"""Test Alice configuring a second transport and setting it as a primary one."""
75+
alice, bob = acfactory.get_online_accounts(2)
76+
77+
bob_addr = bob.get_config("configured_addr")
78+
bob.create_chat(alice)
79+
80+
alice_chat_bob = alice.create_chat(bob)
81+
alice_chat_bob.send_text("Hello!")
82+
83+
msg1 = bob.wait_for_incoming_msg().get_snapshot()
84+
sender_addr1 = msg1.sender.get_snapshot().address
85+
86+
alice.stop_io()
87+
old_alice_addr = alice.get_config("configured_addr")
88+
alice_vcard = alice.self_contact.make_vcard()
89+
assert old_alice_addr in alice_vcard
90+
qr = acfactory.get_account_qr()
91+
alice.add_transport_from_qr(qr)
92+
new_alice_addr = alice.list_transports()[1]["addr"]
93+
with pytest.raises(JsonRpcError):
94+
# Cannot use the address that is not
95+
# configured for any transport.
96+
alice.set_config("configured_addr", bob_addr)
97+
98+
# Load old address so it is cached.
99+
assert alice.get_config("configured_addr") == old_alice_addr
100+
alice.set_config("configured_addr", new_alice_addr)
101+
# Make sure that setting `configured_addr` invalidated the cache.
102+
assert alice.get_config("configured_addr") == new_alice_addr
103+
104+
alice_vcard = alice.self_contact.make_vcard()
105+
assert old_alice_addr not in alice_vcard
106+
assert new_alice_addr in alice_vcard
107+
with pytest.raises(JsonRpcError):
108+
alice.delete_transport(new_alice_addr)
109+
alice.start_io()
110+
111+
alice_chat_bob.send_text("Hello again!")
112+
113+
msg2 = bob.wait_for_incoming_msg().get_snapshot()
114+
sender_addr2 = msg2.sender.get_snapshot().address
115+
116+
assert msg1.sender == msg2.sender
117+
assert sender_addr1 != sender_addr2
118+
assert sender_addr1 == old_alice_addr
119+
assert sender_addr2 == new_alice_addr
120+
121+
122+
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
123+
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
124+
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
125+
Disabling mvbox_move is required to be able to setup a second transport.
126+
"""
127+
account = acfactory.get_unconfigured_account()
128+
129+
account.set_config("fix_is_chatmail", "1")
130+
account.set_config("is_chatmail", is_chatmail)
131+
132+
# The default value when the setting is unset is "1".
133+
# This is not changed for compatibility with old databases
134+
# imported from backups.
135+
assert account.get_config("mvbox_move") == "1"
136+
137+
qr = acfactory.get_account_qr()
138+
account.add_transport_from_qr(qr)
139+
140+
# Once the first transport is set up,
141+
# mvbox_move is disabled.
142+
assert account.get_config("mvbox_move") == "0"
143+
assert account.get_config("is_chatmail") == is_chatmail
144+
145+
146+
def test_reconfigure_transport(acfactory) -> None:
147+
"""Test that reconfiguring the transport works
148+
even if settings not supported for multi-transport
149+
like mvbox_move are enabled."""
150+
account = acfactory.get_online_account()
151+
account.set_config("mvbox_move", "1")
152+
153+
[transport] = account.list_transports()
154+
account.add_or_update_transport(transport)
155+
156+
# Reconfiguring the transport should not reset
157+
# the settings as if when configuring the first transport.
158+
assert account.get_config("mvbox_move") == "1"

deltachat-rpc-client/tests/test_something.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,14 +467,15 @@ def track(e):
467467

468468

469469
def test_wait_next_messages(acfactory) -> None:
470-
alice = acfactory.new_configured_account()
470+
alice = acfactory.get_online_account()
471471

472472
# Create a bot account so it does not receive device messages in the beginning.
473473
addr, password = acfactory.get_credentials()
474474
bot = acfactory.get_unconfigured_account()
475475
bot.set_config("bot", "1")
476476
bot.add_or_update_transport({"addr": addr, "password": password})
477477
assert bot.is_configured()
478+
bot.bring_online()
478479

479480
# There are no old messages and the call returns immediately.
480481
assert not bot.wait_next_messages()

python/examples/test_examples.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def datadir():
1414
return None
1515

1616

17+
@pytest.mark.skip("The test is flaky in CI and crashes the interpreter as of 2025-11-12")
1718
def test_echo_quit_plugin(acfactory, lp):
1819
lp.sec("creating one echo_and_quit bot")
1920
botproc = acfactory.run_bot_process(echo_and_quit)

src/config.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,10 @@ impl Config {
477477

478478
/// Whether the config option needs an IO scheduler restart to take effect.
479479
pub(crate) fn needs_io_restart(&self) -> bool {
480-
matches!(self, Config::MvboxMove | Config::OnlyFetchMvbox)
480+
matches!(
481+
self,
482+
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr
483+
)
481484
}
482485
}
483486

@@ -713,6 +716,16 @@ impl Context {
713716
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
714717
Self::check_config(key, value)?;
715718

719+
let n_transports = self.count_transports().await?;
720+
if n_transports > 1
721+
&& matches!(
722+
key,
723+
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
724+
)
725+
{
726+
bail!("Cannot reconfigure {key} when multiple transports are configured");
727+
}
728+
716729
let _pause = match key.needs_io_restart() {
717730
true => self.scheduler.pause(self).await?,
718731
_ => Default::default(),
@@ -798,10 +811,11 @@ impl Context {
798811
.await?;
799812
}
800813
Config::ConfiguredAddr => {
801-
if self.is_configured().await? {
802-
bail!("Cannot change ConfiguredAddr");
803-
}
804-
if let Some(addr) = value {
814+
let Some(addr) = value else {
815+
bail!("Cannot unset configured_addr");
816+
};
817+
818+
if !self.is_configured().await? {
805819
info!(
806820
self,
807821
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
@@ -812,6 +826,36 @@ impl Context {
812826
.save_to_transports_table(self, &EnteredLoginParam::default())
813827
.await?;
814828
}
829+
self.sql
830+
.transaction(|transaction| {
831+
if transaction.query_row(
832+
"SELECT COUNT(*) FROM transports WHERE addr=?",
833+
(addr,),
834+
|row| {
835+
let res: i64 = row.get(0)?;
836+
Ok(res)
837+
},
838+
)? == 0
839+
{
840+
bail!("Address does not belong to any transport.");
841+
}
842+
transaction.execute(
843+
"UPDATE config SET value=? WHERE keyname='configured_addr'",
844+
(addr,),
845+
)?;
846+
847+
// Clean up SMTP and IMAP APPEND queue.
848+
//
849+
// The messages in the queue have a different
850+
// From address so we cannot send them over
851+
// the new SMTP transport.
852+
transaction.execute("DELETE FROM smtp", ())?;
853+
transaction.execute("DELETE FROM imap_send", ())?;
854+
855+
Ok(())
856+
})
857+
.await?;
858+
self.sql.uncache_raw_config("configured_addr").await;
815859
}
816860
_ => {
817861
self.sql.set_raw_config(key.as_ref(), value).await?;

0 commit comments

Comments
 (0)