Compare commits

...
57 Commits
Author SHA1 Message Date
d2weber eb2e477235 chore: update yerpc 0.7.0 to 0.7.1 (#8868)
This fixes build problems with the generated jsonrpc qt bindings which
occured on UT20.04 builds (uses older qt and gcc versions)

It also resolves a cargo future incompatibily warning
2026-10-28 11:59:47 +01:00
link2xt 9e346ac650 refactor!: remove configured_addr config
configured_addr was already not used by real SMTP code.
Rust tests which emulate sending with TestContext.pop_sent_msg()
now have a way to set the "sending" address manually in memory.
2026-10-27 11:22:52 +00:00
link2xt 4b8c65e7b4 refactor(smtp): handle closed interrupt channel explicitly
Ignoring errors in IMAP code has previously led to busy looping during shutdown in the tests.
If interrupt channel is closed, it is better to exit SMTP loop immediately
without waiting for the task to be cancelled because if we busy loop
the task may even be impossible to cancel.
2026-10-26 16:28:40 +00:00
link2xtandholger krekel 4171c6f74a chore: update preloaded DNS cache
Add all default onboarding relays
and remove domains that don't resolve
with non-responding IPs.

Co-authored-by: holger krekel <holger@merlinux.eu>
2026-10-26 14:05:11 +00:00
holger krekel 5a894d3e37 refactor: read messages inside the markseen_msgs() transaction
Ephemeral timers now start in the same transaction
instead of a separate one before it.
2026-10-26 15:00:44 +01:00
holger krekel 23b4ca05c8 fix: mark messages seen and queue read receipts in one transaction
A failure or an app kill between the separate writes of markseen_msgs()
left a message seen without a queued read receipt, read receipt to self
or IMAP \Seen flag, and the next check would skip it as already seen.
2026-10-26 15:00:44 +01:00
holger krekel e11a55c29f refactor: split a sync insert_imap_markseen() out of markseen_on_imap_table() 2026-10-26 15:00:44 +01:00
holger krekel 5ffcf2a4a4 refactor: make MsgId::start_ephemeral_timer() synchronous
A message seen on another device now gets its state and its
ephemeral timer in one transaction, so it cannot end up seen
without a running timer. A failure to start the timer now fails
like a failure to update the state, instead of being only logged.
2026-10-26 15:00:44 +01:00
holger krekel baec90b70d refactor: make update_msg_state() synchronous and take a transaction 2026-10-26 15:00:44 +01:00
holger krekel da3ee2e025 perf: avoid some full table scans on every IMAP loop iteration and sent message
On large profiles they keep the CPU busy whenever messages are sent or received.
2026-10-26 14:59:50 +01:00
holger krekel a4de550a75 refactor: move expiration and IMAP loop queries into constants 2026-10-26 14:59:50 +01:00
link2xt f3bb558ac3 api!: remove webxdc_realtime_enabled config
It is removed from UIs already, so practically not used.
2026-10-26 13:50:35 +00:00
holger krekel 8789098155 chore: add deny.toml exception for RUSTSEC-2026-0387
Timing side channels in crypto-bigint 0.5.5 are fixed only in 0.7.6,
but rPGP 0.21 depends on elliptic-curve 0.13 and cx448 0.1,
which require crypto-bigint ^0.5.
Delta by default uses only Ed25519 and X25519
(and RSA keys from old profiles) that all do not use crypto-bigint.
2026-10-26 12:38:09 +01:00
link2xt 4ad53321f8 chore: upgrade tokio from 1.53.1 to 1.53.3
Makes cargo-deny stop complaining about https://rustsec.org/advisories/RUSTSEC-2026-0386
This is not even a security issue and we don't use mentioned functions anyway.
2026-10-24 17:14:33 +00:00
link2xt f0be6c387e fix: ignore html/* MIME parts
HTML parts have text/html MIME type.
html/* is not a registered MIME type: https://www.iana.org/assignments/media-types
2026-10-24 17:08:12 +00:00
link2xt 4ce056c85c refactor(scheduler): do not wait for IMAP and SMTP loop to be started
If we spawned a task, we alread know that the loop is started,
there is no need to send empty value from the task back to the scheduler.
2026-10-24 17:01:05 +00:00
link2xt df19d9d1df fix: fix the order of arguments to merge_openpgp_certificates() call
This fixes flaky tests/test_securejoin.py::test_qr_scan_updates_new_relay_address
which hanged during the second securejoin on fast network (e.g. when testing against local container)
as Bob received securejoin pubkey message from Alice but did not merge the new relay list.
2026-10-24 16:52:41 +00:00
link2xt ca4820aec3 test: add test_import_public_key_tiebreak() 2026-10-24 16:52:41 +00:00
link2xt a4363aa308 test: add test_merge_openpgp_certificates_tiebreak()
This tests that the order of arguments
to merge_openpgp_certificates() is important
if signature timestamp is the same.
2026-10-24 16:52:41 +00:00
link2xt 6767500ea2 refactor: factor out Contact.relay_addresses() 2026-10-24 16:52:41 +00:00
link2xt dedc160a2a refactor: pass current timestamp as an argument to signature_subpackets()
pk_encrypt() already uses current timestamp to select the key for encryption,
no need to use a potentially different timestamp to sign the message.
2026-10-24 16:52:41 +00:00
link2xt 7a1df15f92 docs: document arguments to secret_key_to_public_key()
It is not obvious that relay list is comma-separated.
2026-10-24 16:52:41 +00:00
link2xt 1a632bdade chore: update clipboard-win from 5.4.0 to 5.4.3
Versions <= 5.4.1 are yanked, apparently because of this change in unsafe code:
https://github.com/DoumanAsh/clipboard-win/commit/38c3c3fef175c20ed8f8e33dacb9b1e3bb8eca87
Not a big problem because the crate is only used in repl via rustyline dependency,
but cargo-deny complains about the yanked crate.

Diff can be viewed at https://cratediff.com/crate/clipboard-win/5.4.0/5.4.3/
2026-10-24 15:28:38 +00:00
link2xt 98289e8dbb chore: bump verion to 2.66.0-dev 2026-10-22 20:33:10 +00:00
link2xt 706ee7a48c chore(release): prepare for 2.65.0 2026-10-22 20:19:22 +00:00
link2xt 65da01f863 refactor: create all keyupdates in a single transaction 2026-10-22 20:06:10 +00:00
link2xt 7f3cafd792 refactor: make non_chat_headers() synchronous 2026-10-22 20:06:10 +00:00
link2xt 01fcfc17be refactor: make insert_into_smtp() synchronous 2026-10-22 20:06:10 +00:00
link2xt 52de603753 refactor: make message::insert_tombstone() synchronous
It is only doing SQL, so does not need to be async.
This way insert_into_smtp() can use a single transaction
to insert a tombstone and enqueue the message.
2026-10-22 20:06:10 +00:00
B. Petersen 4d4ec874d2 chore: remove unused broadcast icon 2026-10-22 21:57:52 +02:00
link2xt 41eed949ac chore: add deny.toml exception for RUSTSEC-2026-0382
It is a rustls-webpki v0.102.8 dependency of iroh 0.35.0 which cannot be upgraded.
2026-10-22 16:40:22 +00:00
link2xt 53fe350ebb chore: update webpki-roots 0.26.8 to 0.26.11 2026-10-22 16:40:22 +00:00
link2xt 6bf5c07794 chore: update rustls-webpki 0.103.15 to 0.103.16 2026-10-22 16:40:22 +00:00
biørn b4cc114195 feat: show info message for unpinning (#8852)
as recently discussed with @adbenitez and @Amzd, it is better to add an
info message also for unpinning a message:

otherwise the message "Alice pinned a message" may look like a bug if
there is nothing actually pinned as Bob unpinned for whatever reason
afterwards.

technically, this is a simple change (famous last words) as a messages
were always sent on pinning as well as on unpinning, but for unpinning,
they were additionally hidden.

now the "unpin" info message is visible, however, still tuned down: it
is not notified and it does not bubble up in the chatlist (both similar
to many info messages about group changes)

ftr, this is a minor change, and it should not block ongoing releases
wrt pinned messages
2026-10-22 14:55:22 +02:00
link2xt e16e452091 fix: don't ignore join_next() errors in background_fetch_any()
join_next() failing means something went really wrong, e.g. panic inside a task.
We also don't cancel the tasks other than by dropping JoinSet,
so join_next() should never return an error.
2026-10-22 10:10:09 +00:00
link2xt 26ec01deaa docs: remove outdated sentence saying QR scan aborts started securejoin processes
This is not true since bbb267331c
and there is a test test_parallel_securejoin()
2026-10-21 04:56:31 +00:00
link2xt 8e9c16baca chore: update webpki-roots to 1.0.10
It removes compromised emSign CA,
see https://emudhra.we-have-a-problem.com/ for details.
We have duplicate dependency because of iroh 0.35
still dependencing on the old version,
but most important is that IMAP/SMTP connections
are using the new version.
2026-10-20 15:44:09 +00:00
link2xt 8d2919d23e chore: bump version to 2.65.0-dev 2026-10-20 12:02:02 +00:00
link2xt dd4641071e chore(release): prepare for 2.64.0 2026-10-20 11:37:33 +00:00
link2xt 7a129352c8 feat: do not copy Chat-Version header into unprotected headers
spec.md already documents Header Confidentiality Policy
that hides Chat-Version header in encrypted messages,
but so far we have copied the header into unprotected headers for compatibility.

Since 2.45.0 prefetch does not look at the unprotected Chat-Version
do decide if the message should be downloaded
(change b94792706a)
and since 2.50 show_emails setting which controlled
whether only "chat messages" are processed
is removed (change 63596a4940).
2026-10-20 10:58:50 +00:00
link2xt 38749e0e9c fix(imap): do not busy loop if there are no connection candidates and interrupt channel is closed
Without connection candidates IMAP busy loops when you try to stop I/O
because interrupt channel gets closed and recv() always returns an error immediately,
but no connection is attempted afterwards so IMAP loop keeps calling .prepare()
in a loop without ever yielding control to executor, so it cannot even be cancelled.

New JSON-RPC test test_pseudo_transport_start_stop_io reproduces the problem
from CFFI test tests/test_3_offline.py::TestOfflineChat::test_send_lots_of_offline_msgs
which added one transport without connection candidates and started I/O.
2026-10-20 10:17:39 +00:00
gluek c827cb17c8 fix: do not apply channel reaction summaries as reactions
A reaction summary of a broadcast channel is sent as an empty reaction
to the latest channel message. When another device of the channel owner
received it, it removed the owner's own reaction on that message and
queued the message for the next summary. So the owner's devices sent
the summary to each other, and to all subscribers, every 10 minutes
forever.
2026-10-20 08:42:52 +00:00
link2xt 94641df57b refactor: clean up remaining clippy::arithmetic_side_effects in mimeparser
self.parts.remove(i) can panic if "i" is out of range,
but now it is obvious that it will not because "i" is returned from position(),
and clippy is happy.
2026-10-20 06:45:03 +00:00
link2xt 5de39ba6a4 fix: avoid integer overflow when multiplying received image width by height
This is a minor fix, worst case an image with very large dimensions will not be classified as a File.
Mostly removing `clippy::arithmetic_side_effects`.
2026-10-20 06:45:03 +00:00
B. Petersen 286c21a1e5 add jsonrpc for get_summary_text 2026-10-19 23:45:55 +02:00
biørn 6dde761ea5 feat: do not spam channel summaries with reactions (#8829)
while showing reactions for channel summaries was regarded as "let's
try" when channel reactions were introduced, meanwhile, after some
experience, it only clutters and spams the chatlist.

for the subscriber, this is anyways only "you reacted", which is often
wrong as there are other reactions. for the owner it is about some names
they do not know. the idea of having some "feedback" for the owner is
still fulfilled as reactions are (optionally) notified - so owner can
still have the "ping ping ping" and relax :)

finallly, this is also what telegram is doing with their channels
2026-10-19 20:30:30 +02:00
link2xt 9637ecff6b ci: test with Python 3.15
Also moved python version to environment variable
so status checks will be named
"JSON-RPC Python tests (ubuntu-latest, latest)" instead of
"JSON-RPC Python tests (ubuntu-latest, 3.14)" and we don't have to
update the list of required status checks each time the version changes.
We already do the same for Rust.
2026-10-18 13:04:38 +00:00
WofWca dce6438d95 refactor: move call_state_ext to CallInfo 2026-10-16 14:49:42 +00:00
WofWca 151f12b724 fix: races in get_summary_text_ext, improve perf
Remove the two unnecessary DB calls that fetch the message
(which could possibly be different from the current `Message`).

Now the only async thing in `get_summary_text_ext` that remains
is `get_webxdc_info`.
See https://github.com/chatmail/core/pull/8826.
2026-10-16 14:49:42 +00:00
pancake d62438d03c fix: abort scheduler tasks after shutdown timeout (#8832)
The timeout currently owns each IMAP and SMTP JoinHandle. When 30
seconds elapse, dropping the timeout drops the handle and detaches the
task instead of cancelling it. On Haiku, a network task stuck behind a
missed or delayed socket wakeup can therefore outlive scheduler
shutdown, retaining connections and interfering with restart or
active-account deletion.

Borrow each handle while waiting, then explicitly abort and await it if
shutdown times out. This guarantees that scheduler shutdown does not
complete while a timed-out network task is still running.
2026-10-16 10:14:29 +00:00
link2xt 60e1d59182 chore: bump rustls from 0.23.45 to 0.23.46
This fixes
https://rustsec.org/advisories/RUSTSEC-2026-0346
https://rustsec.org/advisories/RUSTSEC-2026-0347
https://rustsec.org/advisories/RUSTSEC-2026-0348
https://rustsec.org/advisories/RUSTSEC-2026-0349
2026-10-16 08:33:45 +00:00
Hocuri 64e58755c0 fix: Encode '@' with '%40' again in QR codes (#8825)
Fix #8824

This commit fixes the following problem:

This is a Delta Chat invite link: https://i.delta.chat/#CFAE4957FFEB8529CC1A5CF568D0EFE4139D5749&v=3&x=8lJEkOodZ01W-Xz5sOvAI1JN&i=XonKX2YYHM14I5c-PBj3YAED&s=Pc4aW8eePr_lO1OFnwUJ5fVB&a=pbkwwo8z7%40nine.testrun.org&r=uydmygf8t@chat.vim.wtf,13xe893jx%40tarpit.fun&n=Alice&g=Great+group

It's linkified correctly on Github, but WhatsApp makes only part of the link clickable, so that the inviter name gets lost. A group invite link additionally loses the group name and gets converted into a 1:1 invite.

When I replace the `@` with `%40`, it gets linkified correctly.

Apparently, WhatsApp tries to be smart and assumes that if there is an `@` in the link, it's almost over, and treats any non-ascii character as the end of the link.

...So, if we want our links to render correctly on WhatsApp, I guess we do need to replace `@` with `%40`. That's a very simple one-line fix (plus tests). It will just make the links look a little bit uglier.
2026-10-14 17:18:30 +02:00
link2xt d766641ce2 feat: switch to another SMTP transport when current one returns an error
Classic email servers sometimes start rejecting all messages and ask the user to solve web CAPTCHA, but still allow to connect.
In this case we want to switch to another SMTP server by moving the transport to the end of the list returned by smtp::sorted_transports.

We disconnect after any message sending failure, so next message or resent same message will be sent over another other transport.

Permanent SMTP error does not necessarily mean that switching to another server will help.
For example if the user tries to send a large message, any server will reject it, and it is not a failure of the server.
We do not try to distinguish between error messages, so it is expected that we may
sometimes switch to another server when it is unnecessary.
2026-10-13 00:43:51 +00:00
link2xt 5510fb5a18 refactor(smtp): introduce smtp::Connection structure
It is now impossible for SMTP client to be connected
without having a From address and transport_id available.

Follow-up to 70730490b2
2026-10-12 23:35:58 +00:00
link2xt 036b928a7f feat: take key flags and expiration into account when selecting the key
Test keys were generated with GnuPG 2.4.9 from Arch Linux.

The script that was used to generate the key for test_select_pk_for_encryption:
```
export GNUPGHOME="$PWD/gnupghome"
rm -fr "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"

# --faked-system-time example is from the gpg man page
GPG="gpg --batch --quiet --faked-system-time 20070924T154812"

USERID='Alice <alice@example.org>'

# Generate the primary key.
# "default" means certification+signing primary key
# "never" to make the key not expire, otherwise gpg generates expiring key by default.
$GPG --passphrase '' --quick-generate-key "$USERID" ed25519 default never

# --quick-add-key requires that keys are referred to by the fingerprint.
# Output of --with-colons is described in /usr/share/doc/gnupg/DETAILS
FINGERPRINT="$($GPG --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10 }')"

# Authentication-only RSA key, should not be used for encryption.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" rsa auth never

# Expired (considering the fake date) subkey.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr 1d

# Signing subkey.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" ed25519 sign never

# Usable encryption subkey.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr never

# Expiring subkey that is not expired in the beginning of 2008.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr 1y

# Another encryption subkey that should not be used because the first one is preferred.
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr never

$GPG --armor --export "$FINGERPRINT" > alice.tpk.asc
```

Key for test_select_only_expired_subkey was generated with:
```
#!/bin/sh
set -e
export GNUPGHOME="$PWD/gnupghome"
rm -fr  "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"

GPG="gpg --batch --quiet --faked-system-time 20070924T154812"
USERID='Alice <alice@example.org>'
$GPG --passphrase '' --quick-generate-key "$USERID" ed25519 default never
FINGERPRINT="$($GPG --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10 }')"
$GPG --passphrase '' --quick-add-key "$FINGERPRINT" cv25519 encr 1y
$GPG --armor --export "$FINGERPRINT" > alice.tpk.asc
```

Key for test_expiring_subkey_selection was generated with:
```
#!/bin/sh
set -e
export GNUPGHOME="$PWD/gnupghome"
rm -fr  "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"

GPG="gpg --batch --quiet"
USERID='Alice <alice@example.org>'

$GPG --passphrase '' --faked-system-time 20250101T000000 --quick-generate-key "$USERID" ed25519 default never
FINGERPRINT="$($GPG --list-secret-keys --with-colons | awk -F: '$1 == "fpr" { print $10 }')"

$GPG --passphrase '' --faked-system-time 20250101T000000 --quick-add-key "$FINGERPRINT" cv25519 encr never

$GPG --passphrase '' --faked-system-time 20260601T000000 --quick-add-key "$FINGERPRINT" cv25519 encr 1m
$GPG --passphrase '' --faked-system-time 20260601T000000 --quick-add-key "$FINGERPRINT" cv25519 encr 2w
$GPG --passphrase '' --faked-system-time 20260608T000000 --quick-add-key "$FINGERPRINT" cv25519 encr 2w

$GPG --armor --export "$FINGERPRINT" > alice.tpk.asc
```
2026-10-10 21:01:24 +00:00
link2xt f3b043f111 refactor: remove Context argument from secret_key_to_public_key() 2026-10-10 21:01:24 +00:00
link2xt 694ef8ba76 chore: bump version to 2.64.0-dev 2026-10-09 17:59:58 +00:00
79 changed files with 1739 additions and 1409 deletions
+10 -8
View File
@@ -25,6 +25,8 @@ env:
# Minimum Supported Rust Version
MSRV: 1.89.0
PYTHON_VERSION: "3.15"
jobs:
lint_rust:
name: Lint Rust
@@ -268,11 +270,11 @@ jobs:
fail-fast: false
matrix:
include:
# Currently used Rust version.
# Currently used Python version.
- os: ubuntu-latest
python: 3.14
python: latest
- os: macos-latest
python: 3.14
python: latest
# PyPy tests
- os: ubuntu-latest
@@ -303,7 +305,7 @@ jobs:
- name: Install python
uses: actions/setup-python@v7.0.0
with:
python-version: ${{ matrix.python }}
python-version: ${{ matrix.python == 'latest' && env.PYTHON_VERSION || matrix.python }}
- name: Install tox
run: pip install tox
@@ -324,11 +326,11 @@ jobs:
matrix:
include:
- os: ubuntu-latest
python: 3.14
python: latest
- os: macos-latest
python: 3.14
python: latest
- os: windows-latest
python: 3.14
python: latest
# PyPy tests
- os: ubuntu-latest
@@ -351,7 +353,7 @@ jobs:
- name: Install python
uses: actions/setup-python@v7.0.0
with:
python-version: ${{ matrix.python }}
python-version: ${{ matrix.python == 'latest' && env.PYTHON_VERSION || matrix.python }}
- name: Install tox
run: pip install tox
+68
View File
@@ -1,5 +1,71 @@
# Changelog
## [2.65.0] - 2026-10-22
### Features / Changes
- show info message for unpinning ([#8852](https://github.com/chatmail/core/pull/8852)).
### Fixes
- don't ignore `join_next()` errors in `background_fetch_any()`.
### Documentation
- remove outdated sentence saying QR scan aborts started securejoin processes.
### Miscellaneous Tasks
- update webpki-roots to 1.0.10.
- update webpki-roots 0.26.8 to 0.26.11.
- update rustls-webpki 0.103.15 to 0.103.16.
- add deny.toml exception for RUSTSEC-2026-0382.
- remove unused broadcast icon.
### Refactor
- make `message::insert_tombstone()` synchronous.
- make `insert_into_smtp()` synchronous.
- make `non_chat_headers()` synchronous.
- create all keyupdates in a single transaction.
## [2.64.0] - 2026-10-20
### API-Changes
- add jsonrpc for `get_summary_text`.
### Features / Changes
- take key flags and expiration into account when selecting the key.
- switch to another SMTP transport when current one returns an error.
- do not spam channel summaries with reactions ([#8829](https://github.com/chatmail/core/pull/8829)).
- do not copy Chat-Version header into unprotected headers.
### Fixes
- Encode '@' with '%40' again in QR codes ([#8825](https://github.com/chatmail/core/pull/8825)).
- abort scheduler tasks after shutdown timeout ([#8832](https://github.com/chatmail/core/pull/8832)).
- do not apply channel reaction summaries as reactions.
- imap: do not busy loop if there are no connection candidates and interrupt channel is closed.
- races in `get_summary_text_ext`, improve perf.
- avoid integer overflow when multiplying received image width by height.
### CI
- test with Python 3.15.
### Miscellaneous Tasks
- bump rustls from 0.23.45 to 0.23.46.
### Refactor
- remove Context argument from `secret_key_to_public_key()`.
- smtp: introduce smtp::Connection structure.
- move `call_state_ext` to `CallInfo`.
- clean up remaining `clippy::arithmetic_side_effects` in `mimeparser`.
## [2.63.0] - 2026-10-09
### API-Changes
@@ -8983,3 +9049,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.61.0]: https://github.com/chatmail/core/compare/v2.60.0..v2.61.0
[2.62.0]: https://github.com/chatmail/core/compare/v2.61.0..v2.62.0
[2.63.0]: https://github.com/chatmail/core/compare/v2.62.0..v2.63.0
[2.64.0]: https://github.com/chatmail/core/compare/v2.63.0..v2.64.0
[2.65.0]: https://github.com/chatmail/core/compare/v2.64.0..v2.65.0
Generated
+38 -29
View File
@@ -334,13 +334,13 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.86"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"syn 3.0.6",
]
[[package]]
@@ -920,9 +920,9 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "clipboard-win"
version = "5.4.0"
version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892"
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -1334,7 +1334,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.63.0"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1415,7 +1415,7 @@ dependencies = [
"url",
"uuid",
"walkdir",
"webpki-roots",
"webpki-roots 1.0.10",
]
[[package]]
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.63.0"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1461,14 +1461,14 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc-bindings"
version = "2.63.0"
version = "2.66.0-dev"
dependencies = [
"deltachat-jsonrpc",
]
[[package]]
name = "deltachat-repl"
version = "2.63.0"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1484,7 +1484,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.63.0"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1514,7 +1514,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.63.0"
version = "2.66.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -2695,7 +2695,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 0.26.11",
]
[[package]]
@@ -3060,7 +3060,7 @@ dependencies = [
"tracing",
"url",
"wasm-bindgen-futures",
"webpki-roots",
"webpki-roots 0.26.11",
"x509-parser",
"z32",
]
@@ -3246,7 +3246,7 @@ dependencies = [
"tokio-websockets",
"tracing",
"url",
"webpki-roots",
"webpki-roots 0.26.11",
"ws_stream_wasm",
"z32",
]
@@ -5132,7 +5132,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"webpki-roots 0.26.11",
"windows-registry",
]
@@ -5289,9 +5289,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"brotli",
"brotli-decompressor",
@@ -5299,7 +5299,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.15",
"rustls-webpki 0.103.16",
"subtle",
"zeroize",
]
@@ -5336,9 +5336,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -6309,9 +6309,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
@@ -6923,9 +6923,18 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "0.26.8"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
@@ -7521,9 +7530,9 @@ dependencies = [
[[package]]
name = "yerpc"
version = "0.7.0"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6924db1f1011f1d22566c45c7090aa66c3107c3c463ca32beaa8b2327127040a"
checksum = "3d7436d4f59711e0a796697630a77c33981c4b5406bdd52e874a08b27ad938ee"
dependencies = [
"anyhow",
"async-channel 1.9.0",
@@ -7541,9 +7550,9 @@ dependencies = [
[[package]]
name = "yerpc_derive"
version = "0.7.0"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42c374248c189b15a7f0660db3984e9dcc790ec15f4b44e932e92be49de829f9"
checksum = "feab3bc81a5743c3661099ef6727b182a27c32686b78831e143786b636b32fee"
dependencies = [
"convert_case",
"darling",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.63.0"
version = "2.66.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
@@ -107,7 +107,7 @@ toml = "0.9"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
walkdir = "2.5.0"
webpki-roots = "0.26.8"
webpki-roots = "1.0.10"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-149
View File
@@ -1,149 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
enable-background="new 0 0 128 128"
viewBox="0 0 60 60"
version="1.1"
id="svg878"
sodipodi:docname="icon-broadcast.svg"
width="60"
height="60"
inkscape:version="1.0.2 (e86c8708, 2021-01-15)"
inkscape:export-filename="/Users/bpetersen/projects/deltachat-core-rust/assets/icon-broadcast.png"
inkscape:export-xdpi="409.60001"
inkscape:export-ydpi="409.60001">
<metadata
id="metadata884">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs882" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1329"
inkscape:window-height="847"
id="namedview880"
showgrid="false"
inkscape:zoom="5.21875"
inkscape:cx="36.598802"
inkscape:cy="32.191617"
inkscape:window-x="111"
inkscape:window-y="205"
inkscape:window-maximized="0"
inkscape:current-layer="svg878"
inkscape:document-rotation="0" />
<radialGradient
id="c"
cx="65.25"
cy="89"
r="26.440001"
gradientTransform="matrix(0.77611266,0.11996647,-0.18999676,1.2286617,-11.305867,-60.065999)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#FFC107"
offset="0"
id="stop833" />
<stop
stop-color="#FFBD06"
offset=".3502"
id="stop835" />
<stop
stop-color="#FFB104"
offset=".6938"
id="stop837" />
<stop
stop-color="#FFA000"
offset="1"
id="stop839" />
</radialGradient>
<radialGradient
id="b"
cx="52.5"
cy="19.75"
r="92.975998"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(45.323856,68.997115,75.979538)">
<stop
stop-color="#EF5350"
offset="0"
id="stop848" />
<stop
stop-color="#EB4F4C"
offset=".246"
id="stop850" />
<stop
stop-color="#E04341"
offset=".4878"
id="stop852" />
<stop
stop-color="#CD302F"
offset=".7272"
id="stop854" />
<stop
stop-color="#C62828"
offset=".8004"
id="stop856" />
<stop
stop-color="#C62828"
offset="1"
id="stop858" />
</radialGradient>
<radialGradient
id="a"
cx="16.979"
cy="92"
r="24.165001"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(45.323856,68.997115,75.979538)"
xlink:href="#b">
<stop
stop-color="#E0E0E0"
offset="0"
id="stop863" />
<stop
stop-color="#CFCFCF"
offset=".3112"
id="stop865" />
<stop
stop-color="#A4A4A4"
offset=".9228"
id="stop867" />
<stop
stop-color="#9E9E9E"
offset="1"
id="stop869" />
</radialGradient>
<rect
y="0"
x="0"
height="60"
width="60"
id="rect1420"
style="fill:#7cc0bc;fill-opacity:1;stroke:none;stroke-width:1.29077" />
<path
id="path872"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.336872;stroke-opacity:1"
d="m 8.6780027,35.573064 0.032831,-11.910176 c 0.00138,-0.476406 0.4881282,-0.794259 0.9235226,-0.604877 l 4.1144877,2.345752 -0.02386,8.656315 -4.1268029,2.122946 C 9.1617452,36.370003 8.6766889,36.049472 8.6780027,35.573064 Z m 5.0469633,-1.508222 0.02386,-8.656314 31.145424,-9.537653 c 0.841472,-0.219211 1.65915,0.41667 1.656755,1.283728 l -0.06929,25.139995 c -0.0024,0.867062 -0.825942,1.500799 -1.663803,1.274581 z m 3.8042,6.892234 C 16.681121,40.104348 16.315444,38.819414 16.69043,37.591308 l 2.252234,-7.347193 c 0.2644,-0.861571 0.845185,-1.567441 1.641953,-1.989251 0.796769,-0.421808 1.706956,-0.509819 2.568531,-0.245419 l 7.263888,2.225804 c 1.775518,0.543235 2.780299,2.432591 2.232297,4.208094 L 30.3971,41.790532 c -0.545627,1.777887 -2.432591,2.780297 -4.208095,2.232298 l -7.263891,-2.225804 c -0.545033,-0.165864 -1.01825,-0.460162 -1.395948,-0.83995 z m 12.377693,-7.976728 c -0.07601,-0.07642 -0.17114,-0.133864 -0.280621,-0.167516 l -7.263891,-2.225803 c -0.233244,-0.07209 -0.421626,0.0013 -0.512275,0.04861 -0.09064,0.0474 -0.25772,0.166033 -0.327435,0.396899 l -2.252234,7.347191 c -0.108166,0.354628 0.09088,0.731541 0.447888,0.842099 l 7.263891,2.225802 c 0.354626,0.108174 0.731539,-0.09088 0.842099,-0.447888 l 2.249845,-7.344814 c 0.07453,-0.245145 0.0014,-0.504991 -0.167267,-0.67458 z" />
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

+1 -5
View File
@@ -31,7 +31,7 @@ use deltachat::internals_for_benches::create_broadcast_secret;
use deltachat::internals_for_benches::save_broadcast_secret;
use deltachat::securejoin::get_securejoin_qr;
use deltachat::{
Events, chat::ChatId, config::Config, context::Context, internals_for_benches::key_from_asc,
Events, chat::ChatId, context::Context, internals_for_benches::key_from_asc,
internals_for_benches::parse_and_get_text, internals_for_benches::store_self_keypair,
stock_str::StockStrings,
};
@@ -57,10 +57,6 @@ async fn create_context() -> Context {
.await
.unwrap();
context
.set_config(Config::ConfiguredAddr, Some("bob@example.net"))
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
store_self_keypair(&context, &secret)
.await
-11
View File
@@ -5,7 +5,6 @@ use std::path::PathBuf;
use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::{
Events,
config::Config,
context::Context,
imex::{ImexMode, imex},
receive_imf::receive_imf,
@@ -109,16 +108,6 @@ async fn create_context() -> Context {
.unwrap();
}
let addr = "alice@example.com";
context.set_config(Config::Addr, Some(addr)).await.unwrap();
context
.set_config(Config::ConfiguredAddr, Some(addr))
.await
.unwrap();
context
.set_config(Config::Configured, Some("1"))
.await
.unwrap();
context
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.63.0"
version = "2.66.0-dev"
description = "Deltachat FFI"
edition = "2024"
license = "MPL-2.0"
+9 -11
View File
@@ -389,9 +389,6 @@ char* dc_get_blobdir (const dc_context_t* context);
/**
* Configure the context. The configuration is handled by key=value pairs as:
*
* - `configured_addr` = Email address in use.
* Unless for testing, do not set this value using dc_set_config().
* Instead, set `addr` and call dc_configure().
* - `displayname` = Own name to use when sending messages. MUAs are allowed to spread this way e.g. using CC, defaults to empty
* - `selfstatus` = Own status to display, e.g. in e-mail footers, defaults to empty
* - `selfavatar` = File containing avatar. Will immediately be copied to the
@@ -468,9 +465,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* e.g. `ui.desktop.foo`, `ui.desktop.linux.bar`, `ui.android.foo`, `ui.dc40.bar`, `ui.bot.simplebot.baz`.
* These keys go to backups and allow easy per-account settings when using @ref dc_accounts_t,
* however, are not handled by the core otherwise.
* - `webxdc_realtime_enabled` = Whether the realtime APIs should be enabled.
* 0 = WebXDC realtime API is disabled and behaves as noop.
* 1 = WebXDC realtime API is enabled (default).
* - `who_can_call_me` = Who can cause call notifications.
* 0 = Everybody (except explicitly blocked contacts),
* 1 = Contacts (default, does not include contact requests),
@@ -481,8 +475,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* if you want to use the deprecated dc_configure() API, such as:
*
* - `addr` = Email address to use for configuration.
* If dc_configure() fails this is not the email address actually in use.
* Use `configured_addr` to find out the email address actually in use.
* - `mail_server` = IMAP-server, guessed if left out
* - `mail_user` = IMAP-username, guessed if left out
* - `mail_pw` = IMAP-password (always needed)
@@ -2613,8 +2605,6 @@ char* dc_get_securejoin_qr_svg (dc_context_t* context, uint32_
* During the handshake, info messages are added to the chat,
* showing progress, success or errors.
*
* Subsequent calls of dc_join_securejoin() will abort previous, unfinished handshakes.
*
* See https://securejoin.delta.chat/ for details about both protocols.
*
* @memberof dc_context_t
@@ -4478,6 +4468,7 @@ int dc_msg_is_info (const dc_msg_t* msg);
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
* - DC_INFO_GROUP_DESCRIPTION_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
* - DC_INFO_MESSAGE_PINNED (71) - Message pinned, UI should scroll to the pinned message returned by dc_msg_get_parent()
* - DC_INFO_MESSAGE_UNPINNED (72) - Message unpinned, UI should scroll to the pinned message returned by dc_msg_get_parent()
*
* For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID.
@@ -4538,6 +4529,7 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_CHAT_E2EE 50
#define DC_INFO_GROUP_DESCRIPTION_CHANGED 70
#define DC_INFO_MESSAGE_PINNED 71
#define DC_INFO_MESSAGE_UNPINNED 72
/**
@@ -6474,7 +6466,7 @@ void dc_event_unref(dc_event_t* event);
* The event is emitted on the device modifying the transports
* as well as on other devices applying the synced change,
* for the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`,
* `delete_transport` or `set_config(configured_addr)`.
* `delete_transport`.
*/
#define DC_EVENT_TRANSPORTS_MODIFIED 2600
@@ -7275,6 +7267,12 @@ void dc_event_unref(dc_event_t* event);
/// @deprecated 2026-08-31
#define DC_STR_PHASING_OUT 245
/// "You unpinned a message."
#define DC_STR_MESSAGE_UNPINNED_BY_YOU 246
/// "Message unpinned by %1$s."
#define DC_STR_MESSAGE_UNPINNED_BY_OTHER 247
/**
* @}
*/
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc-bindings"
version = "2.63.0"
version = "2.66.0-dev"
description = "Autogenerate DeltaChat JSON-RPC API bindings at build time"
edition = "2024"
license = "MPL-2.0"
@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.63.0"
"version": "2.66.0-dev"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.63.0"
version = "2.66.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2024"
license = "MPL-2.0"
+39 -2
View File
@@ -495,6 +495,15 @@ impl CommandApi {
Ok(())
}
/// Adds fake transport that cannot be used to connect.
///
/// Used for offline tests only.
async fn add_pseudo_transport(&self, account_id: u32, addr: String) -> Result<()> {
let context = self.get_context(account_id).await?;
deltachat::transport::add_pseudo_transport(&context, &addr).await?;
Ok(())
}
/// Configures a new email account using the provided parameters
/// and adds it as a transport.
///
@@ -912,8 +921,6 @@ impl CommandApi {
/// During the handshake, info messages are added to the chat,
/// showing progress, success or errors.
///
/// Subsequent calls of `secure_join()` will abort previous, unfinished handshakes.
///
/// See https://securejoin.delta.chat/ for details about both protocols.
///
/// **qr**: The text of the scanned QR code. Typically, the same string as given
@@ -1451,6 +1458,36 @@ impl CommandApi {
MsgId::new(message_id).get_html(&ctx).await
}
/// Returns a message summary.
/// Used for notifications, pinned messages and for copying texts to clipboard.
///
/// - `add_forwarded`: `true`=add a hint if a message was forwarded.
/// `false`=do not add the hint;
/// this is allowed only if the message is shown without a username, e.g. for pinned messages.
/// - `add_type_emoji`: `true`=add the type of the message as an emoji prefix.
/// `false`=do not add type emoji;
/// this is allowed only if the message type is visible otherwise,
/// close to where the emoji would be displayed otherwise
/// (eg. images in pinned messages preceding summary)
/// - `approx_chars`: `>0`=convert linebreaks to spaces and truncate the line at about the given number of characters;
/// for single-line UI elements such as pinned messages or notifications;
/// additionally, UIs will most times clip the text to the available space.
/// `0`=leave linebreaks as is and return the full text, useful e.g. for copying to clipboard.
async fn get_message_summary_text(
&self,
account_id: u32,
message_id: u32,
add_forwarded: bool,
add_type_emoji: bool,
approx_chars: u32,
) -> Result<String> {
let ctx = self.get_context(account_id).await?;
let msg = Message::load_from_db(&ctx, MsgId::new(message_id)).await?;
Ok(msg
.get_summary_text_ext(&ctx, add_forwarded, add_type_emoji, approx_chars as usize)
.await)
}
/// get multiple messages in one call,
/// if loading one message fails the error is stored in the result object in it's place.
///
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.63.0"
version = "2.66.0-dev"
license = "MPL-2.0"
edition = "2024"
repository = "https://github.com/chatmail/core"
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.63.0"
version = "2.66.0-dev"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
@@ -18,6 +18,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Topic :: Communications :: Chat",
"Topic :: Communications :: Email"
]
@@ -139,6 +139,11 @@ class Account:
"""Add a new transport using a QR code."""
yield self._rpc.add_transport_from_qr.future(self.id, qr)
@futuremethod
def add_pseudo_transport(self, addr: str):
"""Add transport for offline tests without connection candidates."""
yield self._rpc.add_pseudo_transport.future(self.id, addr)
@futuremethod
def init_transports(self, qr: Optional[str] = None):
"""Add an initial transport on the chatmail relay that answers fastest.
@@ -376,7 +381,6 @@ class Account:
The function returns immediately and the handshake runs in background, sending
and receiving several messages.
Subsequent calls of `secure_join()` will abort previous, unfinished handshakes.
See https://securejoin.delta.chat/ for protocol details.
:param qrdata: The text of the scanned QR code.
+27 -2
View File
@@ -87,6 +87,31 @@ def test_second_device(acf, alice_and_remote_bob) -> None:
assert new_account.get_config("addr") == remote_eval("bob.get_config('addr')")
def test_old_device_delete_transport(acf, alice_and_remote_bob) -> None:
"""Test setting up old core as the second device for the new core and deleting a transport.
Old core unfortunately expects that deprecated configured_addr config value is set
and fails to delete a transport otherwise. The test makes sure new core sets some configured_addr
for backwards compatibility."""
alice, _alice_contact_bob, remote_eval = alice_and_remote_bob("2.62.0")
alice.set_config("displayname", "Alice123")
qr = acf.get_account_qr()
alice.add_transport_from_qr(qr)
alice_provide_backup_future = alice._rpc.provide_backup.future(alice.id)
qr = alice._rpc.get_backup_qr(alice.id)
remote_eval("locals().setdefault('new_account', bob.manager.add_account())")
remote_eval(f"new_account._rpc.get_backup(new_account.id, {qr!r})")
alice_provide_backup_future()
assert alice.get_config("displayname") == remote_eval("new_account.get_config('displayname')")
first_transport_addr = alice.list_transports()[0]["addr"]
remote_eval(f"new_account.delete_transport({first_transport_addr!r})")
@pytest.mark.parametrize("replace_relay", [False, True], ids=["add", "replace"])
def test_keyupdate_against_core_2_48_march_2026(acf, alice_and_remote_bob, replace_relay):
"""Test 2.48 Bob learns a relay change of Alice from a keyupdate, and is shown nothing."""
@@ -104,7 +129,6 @@ def test_keyupdate_against_core_2_48_march_2026(acf, alice_and_remote_bob, repla
# an accepted chat alone is not enough, a message must have flowed.
alice_chat = alice_contact_bob.create_chat()
alice.set_config("keyupdate_debounce", "1")
old_addr = alice.get_config("configured_addr")
alice_chat.send_text("hi")
assert remote_eval("bob.wait_for_incoming_msg().get_snapshot().text") == "hi"
before = bob_sees()
@@ -113,8 +137,9 @@ def test_keyupdate_against_core_2_48_march_2026(acf, alice_and_remote_bob, repla
# and signature timestamps have one-second resolution:
# without waiting, the re-signed key can tie with the copy Bob holds, keeping his.
time.sleep(2)
old_addr = alice.list_transports()[0]["addr"]
alice.add_transport_from_qr(acf.get_account_qr())
(new_addr,) = [t["addr"] for t in alice.list_transports() if t["addr"] != old_addr]
new_addr = alice.list_transports()[1]["addr"]
if replace_relay:
alice.delete_transport(old_addr)
alice.bring_online()
@@ -50,8 +50,6 @@ SETUP_DATA = b"realtime-setup"
def setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=True):
assert ac1.get_config("webxdc_realtime_enabled") == "1"
assert ac2.get_config("webxdc_realtime_enabled") == "1"
ac1_ac2_chat = ac1.create_chat(ac2)
ac2.create_chat(ac1)
@@ -45,10 +45,8 @@ def test_add_second_address(acf) -> None:
second_addr = account.list_transports()[1]["addr"]
third_addr = account.list_transports()[2]["addr"]
assert account.get_config("configured_addr") == first_addr
account.delete_transport(first_addr)
assert len(account.list_transports()) == 2
assert account.get_config("configured_addr") != first_addr
account.delete_transport(second_addr)
assert len(account.list_transports()) == 1
@@ -187,9 +185,6 @@ def test_transport_synchronization(acf, log) -> None:
wait_for_io_started(ac1)
[transport1, transport3] = ac1.list_transports()
log.section("ac1 changes the sending transport")
ac1.set_config("configured_addr", transport3["addr"])
log.section("ac1 removes the first transport")
ac1.delete_transport(transport1["addr"])
@@ -197,7 +192,6 @@ def test_transport_synchronization(acf, log) -> None:
wait_for_io_started(ac1_clone)
[transport3] = ac1_clone.list_transports()
assert transport3["addr"] == addr3
assert ac1_clone.get_config("configured_addr") == addr3
ac2_chat = ac2.create_chat(ac1)
ac2_chat.send_text("Hello!")
@@ -206,8 +200,8 @@ def test_transport_synchronization(acf, log) -> None:
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "Hello!"
def test_transport_sync_new_as_primary(acf, log) -> None:
"""Test that a transport promoted on one device is usable on other devices."""
def test_receive_message_after_transport_sync(acf) -> None:
"""Test that second device can receive messages after transport synchronization."""
ac1, bob = acf.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
@@ -221,12 +215,7 @@ def test_transport_sync_new_as_primary(acf, log) -> None:
[transport1, transport2] = ac1_transports
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert len(ac1_clone.list_transports()) == 2
assert ac1_clone.get_config("configured_addr") == transport1["addr"]
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport2["addr"])
log.section("ac1_clone receives a message via the new transport")
ac1_chat = ac1.create_chat(bob)
ac1_chat.send_text("Hello!")
bob_chat_id = bob.wait_for_incoming_msg_event().chat_id
@@ -236,22 +225,6 @@ def test_transport_sync_new_as_primary(acf, log) -> None:
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "hello back"
def test_recognize_self_address(acf) -> None:
alice, bob = acf.get_online_accounts(2)
bob_chat = bob.create_chat(alice)
qr = acf.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)
bob_chat.send_text("Hello!")
msg = alice.wait_for_incoming_msg().get_snapshot()
assert msg.chat == alice.create_chat(bob)
def test_transport_limit(acf) -> None:
"""Test transports limit."""
account = acf.get_online_account()
@@ -294,7 +267,6 @@ def test_message_info_imap_urls(acf) -> None:
# Alice switches to another transport and removes the rest of the transports.
new_alice_addr = alice.list_transports()[1]["addr"]
alice.set_config("configured_addr", new_alice_addr)
removed_addrs = []
for transport in alice.list_transports():
if transport["addr"] != new_alice_addr:
@@ -326,7 +298,6 @@ def test_remove_primary_transport(acf, log) -> None:
log.section("Alice removes the primary relay")
alice.delete_transport(transport1["addr"])
assert alice.get_config("configured_addr") == transport2["addr"]
alice.stop_io()
alice.start_io()
@@ -369,7 +340,6 @@ def test_qr_works_after_removing_primary_transport(acf, log) -> None:
def test_background_fetch_from_second_transport(acf, direct_imap, dc):
alice, alice_chat, bob_chat = alice_with_two_transports_and_bob(acf)
[transport1, transport2] = alice.list_transports()
assert alice.get_config("configured_addr") == transport1["addr"]
alice.stop_io()
bob_chat.send_text("hello")
@@ -647,9 +647,8 @@ def test_qr_scan_updates_new_relay_address(acf):
bob.wait_for_securejoin_joiner_success()
for ac in [alice, bob]:
old_addr = ac.get_config("configured_addr")
old_addr = ac.list_transports()[0]["addr"]
ac.add_transport_from_qr(acf.get_account_qr())
ac.set_config("configured_addr", ac.list_transports()[1]["addr"])
ac.delete_transport(old_addr)
bob.secure_join(alice.get_qr_code())
+18 -3
View File
@@ -100,7 +100,6 @@ def test_lowercase_address(acf) -> None:
)
assert account.is_configured()
assert addr_upper != addr
assert account.get_config("configured_addr") == addr
assert account.list_transports()[0]["addr"] == addr
param = account.get_info()["used_transport_settings"]
@@ -168,7 +167,7 @@ def test_list_transports(acf) -> None:
def test_account(acf) -> None:
alice, bob = acf.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr")
bob_addr = bob.list_transports()[0]["addr"]
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("Hello!")
@@ -331,7 +330,7 @@ def test_chat(acf) -> None:
def test_contact(acf) -> None:
alice, bob = acf.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr")
bob_addr = bob.list_transports()[0]["addr"]
alice_contact_bob = alice.create_contact(bob, "Bob")
assert alice_contact_bob == alice.get_contact_by_id(alice_contact_bob.id)
@@ -1489,3 +1488,19 @@ def test_is_sending_finished(dc, acf) -> None:
bob.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert dc.is_sending_finished()
def test_pseudo_transport_start_stop_io(acf):
"""
Test that I/O can be started and stopped for pseudo transport.
Pseudo transport has no connection candidates,
so in the past this resulted in IMAP busy looping
and stop_io() failing to even cancel the loop sometimes.
This is a regression test for this bug.
"""
for i in range(10):
alice = acf.get_unconfigured_account()
alice.add_pseudo_transport(f"alice{i}@example.net")
alice.start_io()
alice.stop_io()
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.63.0"
version = "2.66.0-dev"
description = "DeltaChat JSON-RPC server"
edition = "2024"
license = "MPL-2.0"
@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.63.0"
"version": "2.66.0-dev"
}
+12
View File
@@ -30,9 +30,11 @@ ignore = [
# <https://rustsec.org/advisories/RUSTSEC-2026-0049>
# <https://rustsec.org/advisories/RUSTSEC-2026-0098>
# <https://rustsec.org/advisories/RUSTSEC-2026-0099>
# <https://rustsec.org/advisories/RUSTSEC-2026-0382>
"RUSTSEC-2026-0049",
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
"RUSTSEC-2026-0382",
# Panic in CRL signature checks.
# We do not check CRL and cannot update rustls-webpki 0.102.8
@@ -54,6 +56,14 @@ ignore = [
# We enable PQC for encryption rather than signatures.
# <https://rustsec.org/advisories/RUSTSEC-2025-0144>
"RUSTSEC-2025-0144",
# Timing side channels in crypto-bigint 0.5.5, fixed only in 0.7.6.
# rPGP 0.21 depends on elliptic-curve 0.13 and cx448 0.1,
# which require crypto-bigint^0.5.
# Delta by default uses only Ed25519 and X25519 (and RSA keys from old profiles)
# that do not use crypto-bigint.
# <https://rustsec.org/advisories/RUSTSEC-2026-0387>
"RUSTSEC-2026-0387",
]
[bans]
@@ -98,6 +108,7 @@ skip = [
{ name = "thiserror", version = "1.0.69" },
{ name = "toml_datetime", version = "0.6.11" },
{ name = "wasi", version = "0.11.0+wasi-snapshot-preview1" },
{ name = "webpki-roots", version = "0.26.8" },
{ name = "windows" },
{ name = "windows_aarch64_gnullvm" },
{ name = "windows_aarch64_msvc" },
@@ -127,6 +138,7 @@ allow = [
"BSD-3-Clause",
"BSL-1.0", # Boost Software License 1.0
"CC0-1.0",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"MPL-2.0",
+14
View File
@@ -471,6 +471,19 @@ CREATE TABLE smtp_success (
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
) STRICT;
-- Similar to smtp_success, but records failures.
CREATE TABLE smtp_failure (
-- Sequentially increasing ID of the failure
-- Transport with the highest ID is to be used last.
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-- ID of the transport that was used to attempt sending a message.
transport_id INTEGER UNIQUE NOT NULL,
-- Delete `smtp_failure` rows when the transport is deleted.
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
) STRICT;
-- Table of "sync items" to be grouped into sync messages
-- and sent to own devices.
CREATE TABLE multi_device_sync (
@@ -710,6 +723,7 @@ CREATE TABLE imap (
CREATE INDEX imap_folder ON imap(transport_id, folder);
CREATE INDEX imap_rfc724_mid ON imap(transport_id, rfc724_mid);
CREATE INDEX imap_only_rfc724_mid ON imap(rfc724_mid);
CREATE INDEX imap_pending_deletions ON imap(transport_id, folder) WHERE target != folder;
CREATE TABLE imap_markseen (
id INTEGER PRIMARY KEY NOT NULL,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.63.0"
version = "2.66.0-dev"
license = "MPL-2.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
+1 -6
View File
@@ -370,12 +370,7 @@ class ACFactory:
acname = ac._logid
addr = f"{acname}@offline.org"
lib.dc_add_pseudo_transport(ac._dc_context, as_dc_charpointer(addr))
ac.update_config(
{
"configured_addr": addr,
"displayname": acname,
},
)
ac.set_config("displayname", acname)
self._preconfigure_key(ac)
self._acsetup.init_logging(ac)
assert ac.is_configured(), "Pseudo configured account should look like if it is configured"
-1
View File
@@ -51,7 +51,6 @@ class TestGroupStressTests:
lp.sec("ac2: receiving system message about contact addition")
sysmsg = ac2._evtracker.wait_next_incoming_message()
assert ac5.get_config("configured_addr") in sysmsg.text
assert sysmsg.chat.num_contacts() == 4
lp.sec("ac5: waiting for message about addition to the chat")
+6 -6
View File
@@ -132,7 +132,7 @@ class TestOfflineContact:
def test_create_self_contact(self, acfactory):
ac1 = acfactory.get_pseudo_configured_account()
contact1 = ac1.create_contact(ac1.get_config("configured_addr"))
contact1 = ac1.create_contact(ac1.get_self_contact().addr)
assert contact1.id == 1
def test_get_contacts_and_delete(self, acfactory):
@@ -223,7 +223,7 @@ class TestOfflineChat:
ac2 = acfactory.get_pseudo_configured_account()
chat = ac1.create_group_chat(name="title1")
contact = chat.add_contact(ac2)
assert contact.addr == ac2.get_config("configured_addr")
assert contact.addr == ac2.get_self_contact().addr
assert contact.name == ac2.get_config("displayname")
assert contact.account == ac1
chat.remove_contact(ac2)
@@ -456,7 +456,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("configured_addr")
assert contact2.addr == ac_contact.get_self_contact().addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
@@ -552,7 +552,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("configured_addr")
assert contact2.addr == ac_contact.get_self_contact().addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
@@ -604,7 +604,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("configured_addr")
assert contact2.addr == ac_contact.get_self_contact().addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
@@ -621,7 +621,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("configured_addr")
assert contact2.addr == ac_contact.get_self_contact().addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
+1 -1
View File
@@ -1 +1 @@
2026-10-09
2026-10-22
+44 -40
View File
@@ -123,6 +123,45 @@ impl CallInfo {
Ok(())
}
/// Returns call state.
pub fn call_state(&self) -> CallState {
if self.is_incoming() {
if self.is_accepted() {
if self.is_ended() {
CallState::Completed {
duration: self.duration_seconds(),
}
} else {
CallState::Active
}
} else if self.is_canceled() {
// Call was explicitly canceled
// by the caller before we picked it up.
CallState::Missed
} else if self.is_ended() {
CallState::Declined
} else if self.is_stale() {
CallState::Missed
} else {
CallState::Alerting
}
} else if self.is_accepted() {
if self.is_ended() {
CallState::Completed {
duration: self.duration_seconds(),
}
} else {
CallState::Active
}
} else if self.is_canceled() {
CallState::Canceled
} else if self.is_ended() || self.is_stale() {
CallState::Declined
} else {
CallState::Alerting
}
}
/// Returns true if the call is accepted.
pub fn is_accepted(&self) -> bool {
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
@@ -508,10 +547,10 @@ impl Context {
Ok(self.load_call_by_message(call))
}
// Loads information about the call given the `Message`.
//
// If the `Message` is not a call message, returns `None`
fn load_call_by_message(&self, call: Message) -> Option<CallInfo> {
/// Loads information about the call given the `Message`.
///
/// If the `Message` is not a call message, returns `None`
pub fn load_call_by_message(&self, call: Message) -> Option<CallInfo> {
if call.viewtype != Viewtype::Call {
// This can happen e.g. if a "call accepted"
// or "call ended" message is received
@@ -581,42 +620,7 @@ pub async fn call_state(context: &Context, msg_id: MsgId) -> Result<CallState> {
.load_call_by_id(msg_id)
.await?
.with_context(|| format!("{msg_id} is not a call message"))?;
let state = if call.is_incoming() {
if call.is_accepted() {
if call.is_ended() {
CallState::Completed {
duration: call.duration_seconds(),
}
} else {
CallState::Active
}
} else if call.is_canceled() {
// Call was explicitly canceled
// by the caller before we picked it up.
CallState::Missed
} else if call.is_ended() {
CallState::Declined
} else if call.is_stale() {
CallState::Missed
} else {
CallState::Alerting
}
} else if call.is_accepted() {
if call.is_ended() {
CallState::Completed {
duration: call.duration_seconds(),
}
} else {
CallState::Active
}
} else if call.is_canceled() {
CallState::Canceled
} else if call.is_ended() || call.is_stale() {
CallState::Declined
} else {
CallState::Alerting
};
Ok(state)
Ok(call.call_state())
}
/// ICE server for JSON serialization.
+8 -10
View File
@@ -2893,15 +2893,9 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
let (queued_msg, side_effects) = queued_msg_pair;
if needs_encryption && !is_encrypted {
let addr = context.get_config(Config::ConfiguredAddr).await?;
let text = stock_str::unencrypted_email(
context,
addr.unwrap_or_default()
.split('@')
.nth(1)
.unwrap_or_default(),
)
.await;
let addr = context.get_primary_self_addr().await?;
let text =
stock_str::unencrypted_email(context, addr.split('@').nth(1).unwrap_or_default()).await;
message::set_msg_failed(context, msg, &text).await?;
add_info_msg_with_cmd(
context,
@@ -4692,7 +4686,11 @@ pub(crate) async fn resend_msgs_ext(
// Broadcast owners shouldn't see spinners on messages being auto-re-sent to new
// subscribers (otherwise big channel owners will see spinners most of the time).
if to_fingerprint.is_none() {
message::update_msg_state(context, msg.id, MessageState::OutPending).await?;
let sql = &context.sql;
sql.transaction(|tx| {
message::update_msg_state(tx, msg.id, MessageState::OutPending)
})
.await?;
}
}
msg_state => bail!("Unexpected message state {msg_state}"),
+4 -67
View File
@@ -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};
@@ -197,11 +197,6 @@ pub enum Config {
#[strum(props(default = "0"))]
DeleteDeviceAfter,
/// Deprecated(2026-09).
///
/// Use ConfiguredLoginParam and list_transports() instead.
ConfiguredAddr,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
@@ -442,10 +437,6 @@ pub enum Config {
/// MsgId of webxdc map integration.
WebxdcIntegration,
/// Enable webxdc realtime features.
#[strum(props(default = "1"))]
WebxdcRealtimeEnabled,
/// Last device token stored on the chatmail server.
///
/// If it has not changed, we do not store
@@ -752,34 +743,6 @@ impl Context {
.set_raw_config(key.as_ref(), value.map(|s| s.to_lowercase()).as_deref())
.await?;
}
Config::ConfiguredAddr => {
let Some(addr) = value else {
bail!("Cannot unset configured_addr");
};
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(
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('configured_addr', ?)",
(addr,),
)?;
Ok(())
})
.await?;
self.sql.uncache_raw_config("configured_addr").await;
}
_ => {
self.sql.set_raw_config(key.as_ref(), value).await?;
}
@@ -854,18 +817,6 @@ impl Context {
/// Determine whether the specified addr maps to the/a self addr.
/// Returns `false` if no addresses are configured.
pub(crate) async fn is_self_addr(&self, addr: &str) -> Result<bool> {
// Employ the config cache to optimize for `ConfiguredAddr` passed.
if !addr.is_empty()
&& addr_cmp(
addr,
&self
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default(),
)
{
return Ok(true);
}
Ok(self
.get_self_addrs()
.await?
@@ -873,20 +824,6 @@ impl Context {
.any(|a| addr_cmp(addr, a)))
}
/// Sets `primary_new` as the address used for sending.
///
/// This should only be used by test code and during configure.
#[cfg(test)] // AEAP is disabled, but there are still tests for it
pub(crate) async fn set_primary_self_addr(&self, primary_new: &str) -> Result<()> {
self.quota.write().await.clear();
self.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(primary_new))
.await?;
self.emit_event(EventType::ConnectivityChanged);
Ok(())
}
/// Returns all self addresses, newest first.
pub(crate) async fn get_self_addrs(&self) -> Result<Vec<String>> {
let query_only = true;
@@ -895,10 +832,10 @@ impl Context {
.await
}
/// Returns the address of the transport used for sending.
/// Returns an error if no self addr is configured.
/// Returns address of the first transport.
pub async fn get_primary_self_addr(&self) -> Result<String> {
self.get_config(Config::ConfiguredAddr)
self.sql
.query_get_value("SELECT addr FROM transports ORDER BY id LIMIT 1", ())
.await?
.context("No self addr configured")
}
+8 -12
View File
@@ -37,8 +37,8 @@ use crate::sync::Sync::Nosync;
use crate::tools::time;
use crate::transport::{
ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam,
ConnectionCandidate, delete_transport_row, maybe_update_sending_transport,
purge_transport_caches, send_sync_transports, transport_addrs,
ConnectionCandidate, delete_transport_row, purge_transport_caches, send_sync_transports,
transport_addrs, update_configured_addr,
};
use crate::{EventType, autorelay, stock_str};
@@ -275,7 +275,7 @@ impl Context {
/// another one is chosen automatically.
pub async fn delete_transport(&self, addr: &str) -> Result<()> {
let now = time();
let (removed_transport_id, reelected) = self
let removed_transport_id = self
.sql
.transaction(|transaction| {
if transport_addrs(transaction)?.len() <= 1 {
@@ -294,14 +294,10 @@ impl Context {
let remove_timestamp = std::cmp::max(now, add_timestamp);
let transport_id = delete_transport_row(transaction, addr, remove_timestamp)?
.context("Transport disappeared")?;
let reelected = maybe_update_sending_transport(transaction)?;
Ok((transport_id, reelected))
update_configured_addr(transaction)?;
Ok(transport_id)
})
.await?;
if let Some(new_addr) = reelected {
info!(self, "Using transport {new_addr:?} for sending now.");
self.sql.uncache_raw_config("configured_addr").await;
}
send_sync_transports(self).await?;
purge_transport_caches(self, removed_transport_id).await;
// Restarting all IO also stops the removed transport's IMAP loop.
@@ -513,6 +509,8 @@ pub(crate) async fn configure(
let smtp_password = configured_param.smtp_password.clone();
let smtp_addr = configured_param.addr.clone();
let transport_id = 0;
let proxy_config2 = proxy_config.clone();
let smtp_config_task = task::spawn(async move {
let mut smtp = Smtp::new();
@@ -522,6 +520,7 @@ pub(crate) async fn configure(
&smtp_password,
&proxy_config2,
&smtp_addr,
transport_id,
strict_tls,
)
.await?;
@@ -533,7 +532,6 @@ pub(crate) async fn configure(
// Configure IMAP
let transport_id = 0;
let (_s, r) = async_channel::bounded(1);
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
let imap_session = match imap.connect(ctx).await {
@@ -799,8 +797,6 @@ mod tests {
let t = &tcm.unconfigured().await;
add_pseudo_transport(t, "primary@example.org").await?;
t.set_config(Config::ConfiguredAddr, Some("primary@example.org"))
.await?;
assert_eq!(t.count_transports().await?, 1);
t.check_relay_limit().await?;
+27 -38
View File
@@ -372,7 +372,11 @@ pub(crate) async fn import_public_key(
.await?
{
let old_public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
merged_public_key = merge_openpgp_certificates(public_key.clone(), old_public_key)
// Order of merge_openpgp_certificates() arguments is important.
// Second argument is the new key, it is preferred in case of ties,
// i.e. same signature timestamps.
merged_public_key = merge_openpgp_certificates(old_public_key, public_key.clone())
.context("Failed to merge public keys")?;
&merged_public_key
} else {
@@ -718,9 +722,9 @@ impl Contact {
.await?
.unwrap_or_default();
contact.addr = context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default();
.get_primary_self_addr()
.await
.context("Cannot get address for self-contact")?;
if let Some(self_fp) = self_fingerprint_opt(context).await? {
contact.fingerprint = Some(self_fp.to_string());
}
@@ -1421,10 +1425,6 @@ WHERE addr=?
);
let contact = Contact::get_by_id(context, contact_id).await?;
let addr = context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default();
let Some(fingerprint_other) = contact.fingerprint() else {
return Ok(stock_str::encr_none(context));
@@ -1444,37 +1444,15 @@ WHERE addr=?
.await?
.dc_fingerprint()
.human_readable();
if addr < contact.addr {
cat_fingerprint(
&mut ret,
&stock_str::self_msg(context),
&addr,
&fingerprint_self,
);
cat_fingerprint(
&mut ret,
contact.get_display_name(),
&contact.addr,
&fingerprint_other,
);
if fingerprint_self < fingerprint_other {
cat_fingerprint(&mut ret, &stock_str::self_msg(context), &fingerprint_self);
cat_fingerprint(&mut ret, contact.get_display_name(), &fingerprint_other);
} else {
cat_fingerprint(
&mut ret,
contact.get_display_name(),
&contact.addr,
&fingerprint_other,
);
cat_fingerprint(
&mut ret,
&stock_str::self_msg(context),
&addr,
&fingerprint_self,
);
cat_fingerprint(&mut ret, contact.get_display_name(), &fingerprint_other);
cat_fingerprint(&mut ret, &stock_str::self_msg(context), &fingerprint_self);
}
if let Some(public_key) = contact.public_key(context).await?
&& let Some(relay_addrs) = addresses_from_public_key(&public_key)
{
if let Some(relay_addrs) = contact.relay_addresses(context).await? {
ret += "\n\nRelays:";
for relay in &relay_addrs {
ret += "\n";
@@ -1604,6 +1582,17 @@ WHERE addr=?
}
}
/// Returns a list of relay addresses for the contact if it is distributed in the public key.
pub(crate) async fn relay_addresses(&self, context: &Context) -> Result<Option<Vec<String>>> {
if let Some(public_key) = self.public_key(context).await?
&& let Some(relay_addrs) = addresses_from_public_key(&public_key)
{
Ok(Some(relay_addrs))
} else {
Ok(None)
}
}
/// Get name authorized by the contact.
pub fn get_authname(&self) -> &str {
&self.authname
@@ -1968,8 +1957,8 @@ pub(crate) async fn update_last_seen(
Ok(())
}
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
fn cat_fingerprint(ret: &mut String, name: &str, fingerprint: &str) {
*ret += &format!("\n\n{name}:\n{fingerprint}");
}
fn split_address_book(book: &str) -> Vec<(&str, &str)> {
+48 -6
View File
@@ -3,6 +3,7 @@ use deltachat_contact_tools::{addr_cmp, may_be_valid_addr};
use super::*;
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::key::secret_key_to_public_key;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync};
@@ -887,11 +888,11 @@ async fn test_contact_get_encrinfo() -> Result<()> {
"Messages are end-to-end encrypted.
Fingerprints:
Me (alice@example.org):
Me:
2E6F A2CB 23B5 32D7 2863
4B58 64B0 8F61 A9ED 9443
bob@example.net (bob@example.net):
bob@example.net:
CCCB 5AA9 F6E1 141C 9431
65F1 DB18 B18C BCF7 0487
@@ -908,11 +909,11 @@ bob@example.net"
"No encryption.
Fingerprints:
Me (alice@example.org):
Me:
2E6F A2CB 23B5 32D7 2863
4B58 64B0 8F61 A9ED 9443
bob@example.net (bob@example.net):
bob@example.net:
CCCB 5AA9 F6E1 141C 9431
65F1 DB18 B18C BCF7 0487"
);
@@ -1252,7 +1253,7 @@ async fn test_make_n_import_vcard() -> Result<()> {
tokio::fs::write(&avatar_path, avatar_bytes).await?;
bob.set_config(Config::Selfavatar, Some(avatar_path.to_str().unwrap()))
.await?;
let bob_addr = bob.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_addr = bob.get_primary_self_addr().await?;
let bob_biography = bob.get_config(Config::Selfstatus).await?.unwrap();
let chat = bob.create_chat(alice).await;
let sent_msg = bob.send_text(chat.id, "moin").await;
@@ -1348,7 +1349,7 @@ async fn test_make_n_import_vcard() -> Result<()> {
async fn test_import_vcard_key_change() -> Result<()> {
let alice = &TestContext::new_alice().await;
let bob = &TestContext::new_bob().await;
let bob_addr = &bob.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_addr = &bob.get_primary_self_addr().await?;
bob.set_config(Config::Displayname, Some("Bob")).await?;
let vcard = make_vcard(bob, &[ContactId::SELF]).await?;
alice.evtracker.clear_events();
@@ -1560,3 +1561,44 @@ async fn test_same_avatar_no_changed_event() -> Result<()> {
Ok(())
}
/// Tests that relay list from imported key is preferred if the signature
/// has the same timestamp as already imported key.
/// If they key is signed twice with different relay list at the same second,
/// we want to use the relay list from the message that arrives last
/// so the newest relay list is used unless the messages are reordered.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_public_key_tiebreak() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let contact = bob.add_or_lookup_contact(alice).await;
let secret_key = test_utils::alice_keypair();
let timestamp = pgp::types::Timestamp::now();
let addr = "alice@example.net";
let relays1 = "alice@example.net,alice@example.org";
let relays2 = "alice@example.net,alice@example.com";
let relays1_vec: Vec<String> = relays1.split(',').map(String::from).collect();
let relays2_vec: Vec<String> = relays2.split(',').map(String::from).collect();
let public_key1 =
secret_key_to_public_key(secret_key.clone(), timestamp.as_secs(), addr, relays1)?;
let public_key2 = secret_key_to_public_key(secret_key, timestamp.as_secs(), addr, relays2)?;
import_public_key(bob, &public_key1).await?;
assert_eq!(contact.relay_addresses(bob).await?.unwrap(), relays1_vec);
import_public_key(bob, &public_key2).await?;
assert_eq!(contact.relay_addresses(bob).await?.unwrap(), relays2_vec);
import_public_key(bob, &public_key1).await?;
assert_eq!(contact.relay_addresses(bob).await?.unwrap(), relays1_vec);
import_public_key(bob, &public_key2).await?;
assert_eq!(contact.relay_addresses(bob).await?.unwrap(), relays2_vec);
Ok(())
}
-6
View File
@@ -949,12 +949,6 @@ impl Context {
"gossip_period",
self.get_config_int(Config::GossipPeriod).await?.to_string(),
);
res.insert(
"webxdc_realtime_enabled",
self.get_config_bool(Config::WebxdcRealtimeEnabled)
.await?
.to_string(),
);
res.insert(
"donation_request_next_check",
self.get_config_i64(Config::DonationRequestNextCheck)
+77 -104
View File
@@ -279,65 +279,20 @@ pub(crate) async fn stock_ephemeral_timer_changed(
}
impl MsgId {
/// Returns ephemeral message timer value for the message.
pub(crate) async fn ephemeral_timer(self, context: &Context) -> Result<Timer> {
let res = match context
.sql
.query_get_value("SELECT ephemeral_timer FROM msgs WHERE id=?", (self,))
.await?
{
None => Timer::Disabled,
Some(duration) => Timer::from_u32(duration),
};
Ok(res)
}
/// Starts ephemeral message timer for the message if it is not started yet.
pub(crate) async fn start_ephemeral_timer(self, context: &Context) -> Result<()> {
if let Timer::Enabled { duration } = self.ephemeral_timer(context).await? {
let ephemeral_timestamp = time().saturating_add(duration.get().into());
context
.sql
.execute(
"UPDATE msgs SET ephemeral_timestamp = ? \
WHERE (ephemeral_timestamp == 0 OR ephemeral_timestamp > ?) \
AND id = ?",
(ephemeral_timestamp, ephemeral_timestamp, self),
)
.await?;
context.scheduler.interrupt_ephemeral_task().await;
}
Ok(())
///
/// Returns whether the timer was started.
pub(crate) fn start_ephemeral_timer(self, tx: &rusqlite::Transaction) -> Result<bool> {
let updated = tx.execute(
"UPDATE msgs SET ephemeral_timestamp = ?1 + ephemeral_timer
WHERE (ephemeral_timestamp == 0 OR ephemeral_timestamp > ?1 + ephemeral_timer)
AND ephemeral_timer > 0 AND id=?2",
(time(), self),
)?;
Ok(updated > 0)
}
}
pub(crate) async fn start_ephemeral_timers_msgids(
context: &Context,
msg_ids: &[MsgId],
) -> Result<()> {
let now = time();
let should_interrupt =
context
.sql
.transaction(move |transaction| {
let mut should_interrupt = false;
let mut stmt =
transaction.prepare(
"UPDATE msgs SET ephemeral_timestamp = ?1 + ephemeral_timer
WHERE (ephemeral_timestamp == 0 OR ephemeral_timestamp > ?1 + ephemeral_timer) AND ephemeral_timer > 0
AND id=?2")?;
for msg_id in msg_ids {
should_interrupt |= stmt.execute((now, msg_id))? > 0;
}
Ok(should_interrupt)
}).await?;
if should_interrupt {
context.scheduler.interrupt_ephemeral_task().await;
}
Ok(())
}
/// Starts ephemeral timer for all messages in the chat.
///
/// This should be called when chat is marked as noticed.
@@ -379,7 +334,7 @@ async fn select_expired_messages(
SELECT id, chat_id, type, pinned, location_id
FROM msgs
WHERE
ephemeral_timestamp != 0
ephemeral_timestamp > 0
AND ephemeral_timestamp <= ?
AND chat_id != ?
"#,
@@ -560,6 +515,14 @@ async fn next_delete_device_after_timestamp(context: &Context) -> Result<Option<
}
}
/// `> 0` rather than `!= 0`, so that SQLite uses `msgs_index8`.
const NEXT_EPHEMERAL_TIMESTAMP: &str = "
SELECT min(ephemeral_timestamp)
FROM msgs
WHERE ephemeral_timestamp > 0
AND chat_id != ?
HAVING count(*) > 0";
/// Calculates next timestamp when expiration of some message will happen.
///
/// Expiration can happen either because user has set `delete_device_after` setting or because the
@@ -568,13 +531,7 @@ async fn next_expiration_timestamp(context: &Context) -> Option<i64> {
let ephemeral_timestamp: Option<i64> = match context
.sql
.query_get_value(
r#"
SELECT min(ephemeral_timestamp)
FROM msgs
WHERE ephemeral_timestamp != 0
AND chat_id != ?
HAVING count(*) > 0
"#,
NEXT_EPHEMERAL_TIMESTAMP,
(ChatId::TRASH,), // Trash contains already deleted messages, skip them
)
.await
@@ -661,6 +618,60 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
}
}
/// Driven by `imap`: with `bcc_self` off and `force_encryption` on core deletes downloaded
/// messages from the server and their `imap` rows with them, leaving rows only for messages
/// it does not download.
const MARK_DOWNLOADED_FOR_DELETION: &str = "
UPDATE imap
SET target=''
WHERE transport_id=?1 AND target!=''
AND (EXISTS (
SELECT 1 FROM msgs
WHERE rfc724_mid=imap.rfc724_mid
AND id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR download_state=?3))
OR EXISTS (
SELECT 1 FROM msgs
WHERE pre_rfc724_mid=imap.rfc724_mid
AND pre_rfc724_mid!=''
AND id>9))";
/// Driven by `imap` like [`MARK_DOWNLOADED_FOR_DELETION`]: with `bcc_self` and
/// `force_encryption` off core deletes only encrypted and expired messages from the server,
/// so the rows of other messages remain.
const MARK_ENCRYPTED_FOR_DELETION: &str = "
UPDATE imap
SET target=''
WHERE transport_id=?1 AND target!=''
AND (EXISTS (
SELECT 1 FROM msgs
WHERE rfc724_mid=imap.rfc724_mid
AND id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
((param GLOB '*\nc=1*' OR param GLOB 'c=1*') AND download_state=?3)))
OR EXISTS (
SELECT 1 FROM msgs
WHERE pre_rfc724_mid=imap.rfc724_mid
AND pre_rfc724_mid!=''
AND id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
(param GLOB '*\nc=1*' OR param GLOB 'c=1*'))))";
/// Driven by `msgs_index8`: in multi-device mode core leaves downloaded messages on the server,
/// so the `imap` table keeps a row for every message core has not deleted there.
const MARK_EXPIRED_FOR_DELETION: &str = "
UPDATE imap
SET target=''
WHERE transport_id=?1 AND target!=''
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ephemeral_timestamp>0 AND ephemeral_timestamp<=?2 AND id>9
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND ephemeral_timestamp>0 AND ephemeral_timestamp<=?2 AND id>9
)";
/// Schedules expired IMAP messages for deletion on the server.
///
/// Also see [`delete_expired_messages`],
@@ -684,18 +695,7 @@ pub(crate) async fn delete_expired_imap_messages(
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE transport_id=?1
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR download_state=?3)
AND id>9
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND id>9
)",
MARK_DOWNLOADED_FOR_DELETION,
(transport_id, now, DownloadState::Done),
)
.await?;
@@ -705,20 +705,7 @@ pub(crate) async fn delete_expired_imap_messages(
// Only delete expired ephemeral messages.
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE transport_id=?1
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2 AND id>9
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2 AND id>9
)",
(transport_id, now),
)
.execute(MARK_EXPIRED_FOR_DELETION, (transport_id, now))
.await?;
} else {
// Single device, but unencrypted messages are allowed.
@@ -726,21 +713,7 @@ pub(crate) async fn delete_expired_imap_messages(
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE transport_id=?1
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
((param GLOB '*\nc=1*' OR param GLOB 'c=1*') AND download_state=?3))
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
(param GLOB '*\nc=1*' OR param GLOB 'c=1*'))
)",
MARK_ENCRYPTED_FOR_DELETION,
(transport_id, now, DownloadState::Done),
)
.await?;
+25
View File
@@ -576,6 +576,31 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_expiration_queries_do_not_scan_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
let mut scans = Vec::new();
for (name, query) in [
("NEXT_EPHEMERAL_TIMESTAMP", NEXT_EPHEMERAL_TIMESTAMP),
("MARK_DOWNLOADED_FOR_DELETION", MARK_DOWNLOADED_FOR_DELETION),
("MARK_EXPIRED_FOR_DELETION", MARK_EXPIRED_FOR_DELETION),
("MARK_ENCRYPTED_FOR_DELETION", MARK_ENCRYPTED_FOR_DELETION),
] {
for step in t.query_plan(query).await? {
// These statements run after every IMAP fetch or sent message. `SCAN msgs` reads
// the whole table, and so does a rowid range from `id>9`, which SQLite plans as
// `SEARCH msgs USING INTEGER PRIMARY KEY (rowid>?)`. Index lookups and ranges pass.
if step.starts_with("SCAN msgs")
|| step.ends_with("msgs USING INTEGER PRIMARY KEY (rowid>?)")
{
scans.push(format!("{name}: {step}"));
}
}
}
assert!(scans.is_empty(), "{scans:#?}");
Ok(())
}
// Regression test for a bug in the timer rollback protection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_timer_references() -> Result<()> {
+66 -49
View File
@@ -16,8 +16,7 @@ use std::{
use anyhow::{Context as _, Result, bail, ensure, format_err};
use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, UnsolicitedResponse};
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use futures::TryStreamExt as _;
use ratelimit::Ratelimit;
use tokio_util::sync::CancellationToken;
use url::Url;
@@ -63,6 +62,20 @@ const RFC724MID_UID: &str = "(UID BODY.PEEK[HEADER.FIELDS (\
)])";
const BODY_FULL: &str = "(FLAGS BODY.PEEK[])";
const PENDING_DELETIONS: &str = "
SELECT id, uid, target FROM imap
WHERE folder = ?
AND transport_id = ?
AND target != folder
ORDER BY target, uid";
/// `CROSS JOIN` loops over the few `imap_markseen` rows, not over the transport's `imap` rows.
const PENDING_SEEN_FLAGS: &str = "
SELECT imap.id, uid, folder FROM imap_markseen CROSS JOIN imap
WHERE imap.id = imap_markseen.id
AND imap.transport_id=?
AND target = folder";
#[derive(Debug)]
pub(crate) struct Imap {
/// ID of the transport configuration in the `transports` table.
@@ -274,17 +287,27 @@ impl Imap {
"IMAP got rate limited, waiting for {} until can connect.",
duration_to_str(ratelimit_duration),
);
let interrupted = async {
tokio::time::sleep(ratelimit_duration).await;
false
}
.race(self.idle_interrupt_receiver.recv().map(|_| true))
.await;
if interrupted {
info!(
context,
"Connecting to IMAP without waiting for ratelimit due to interrupt."
);
match tokio::time::timeout(ratelimit_duration, self.idle_interrupt_receiver.recv())
.await
{
Ok(Ok(())) => {
info!(
context,
"Connecting to IMAP without waiting for ratelimit due to interrupt."
);
}
Ok(Err(async_channel::RecvError)) => {
// If we get a RecvError, then this is likely because
// the scheduler dropped the sender as it is stopping I/O.
// Returning an error right away would make inbox_loop() call prepare() again.
// Sleeping gives the executor a chance to cancel the loop;
// the exact duration does not matter.
tokio::time::sleep(Duration::from_secs(60)).await;
bail!("Not connecting to IMAP because interrupt channel is closed");
}
Err(_) => {
// Rate limit duration has passed.
}
}
}
@@ -301,6 +324,11 @@ impl Imap {
self.conn_backoff_ms = max(BACKOFF_MIN_MS, self.conn_backoff_ms);
let login_params = prioritize_server_login_params(&context.sql, &self.lp, "imap").await?;
if login_params.is_empty() {
// Count one connection attempt so we don't busy loop.
self.ratelimit.send();
}
let mut first_connection_error = None;
let mut first_login_error = None;
'candidate: for lp in login_params {
@@ -453,8 +481,7 @@ impl Imap {
context.scheduler.interrupt_ephemeral_task().await;
}
// Mark expired messages for deletion. Note that `delete_expired_imap_messages` is
// not well optimized and should not be called before fetching.
// Mark messages for deletion on the server.
delete_expired_imap_messages(context, session.transport_id())
.await
.context("delete_expired_imap_messages")?;
@@ -854,20 +881,12 @@ impl Session {
let transport_id = self.transport_id();
let rows = context
.sql
.query_map_vec(
"SELECT id, uid, target FROM imap
WHERE folder = ?
AND transport_id = ?
AND target != folder
ORDER BY target, uid",
(folder, transport_id),
|row| {
let rowid: i64 = row.get(0)?;
let uid: u32 = row.get(1)?;
let target: String = row.get(2)?;
Ok((rowid, uid, target))
},
)
.query_map_vec(PENDING_DELETIONS, (folder, transport_id), |row| {
let rowid: i64 = row.get(0)?;
let uid: u32 = row.get(1)?;
let target: String = row.get(2)?;
Ok((rowid, uid, target))
})
.await?;
for (target, rowid_set, uid_set) in UidGrouper::from(rows) {
@@ -916,19 +935,12 @@ impl Session {
let transport_id = self.transport_id();
let mut rows = context
.sql
.query_map_vec(
"SELECT imap.id, uid, folder FROM imap, imap_markseen
WHERE imap.id = imap_markseen.id
AND imap.transport_id=?
AND target = folder",
(transport_id,),
|row| {
let rowid: i64 = row.get(0)?;
let uid: u32 = row.get(1)?;
let folder: String = row.get(2)?;
Ok((rowid, uid, folder))
},
)
.query_map_vec(PENDING_SEEN_FLAGS, (transport_id,), |row| {
let rowid: i64 = row.get(0)?;
let uid: u32 = row.get(1)?;
let folder: String = row.get(2)?;
Ok((rowid, uid, folder))
})
.await?;
// Number of SQL results is expected to be low as
@@ -1579,19 +1591,24 @@ pub(crate) async fn prefetch_should_download(
/// Schedule marking the message as Seen on IMAP by adding all known IMAP messages corresponding to
/// the given Message-ID to `imap_markseen` table.
pub(crate) async fn markseen_on_imap_table(context: &Context, message_id: &str) -> Result<()> {
context
.sql
.execute(
"INSERT OR IGNORE INTO imap_markseen (id)
SELECT id FROM imap WHERE rfc724_mid=?",
(message_id,),
)
let sql = &context.sql;
sql.transaction(|tx| insert_imap_markseen(tx, message_id))
.await?;
context.scheduler.interrupt_inbox().await;
Ok(())
}
/// Like [`markseen_on_imap_table`], but leaves interrupting the inbox loop to the caller.
pub(crate) fn insert_imap_markseen(tx: &rusqlite::Transaction, message_id: &str) -> Result<()> {
tx.execute(
"INSERT OR IGNORE INTO imap_markseen (id)
SELECT id FROM imap WHERE rfc724_mid=?",
(message_id,),
)?;
Ok(())
}
/// uid_next is the next unique identifier value from the last time we fetched a folder
/// See <https://tools.ietf.org/html/rfc3501#section-2.3.1.1>
/// This function is used to update our uid_next after fetching messages.
+25
View File
@@ -116,3 +116,28 @@ fn test_uid_grouper() {
vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())]
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_loop_queries_do_not_scan_imap() -> Result<()> {
let t = TestContext::new_alice().await;
let mut scans = Vec::new();
for (name, query) in [
("PENDING_DELETIONS", PENDING_DELETIONS),
("PENDING_SEEN_FLAGS", PENDING_SEEN_FLAGS),
] {
for step in t.query_plan(query).await? {
// A step on the `imap` table (not `imap_markseen`) may only look up single rows
// by rowid or read the partial index of rows pending deletion. With any other index
// SQLite walks at least every `imap` row of the transport.
let reads_imap = step.contains(" imap ") || step.ends_with(" imap");
if reads_imap
&& !step.contains("USING INTEGER PRIMARY KEY (rowid=?)")
&& !step.contains("USING INDEX imap_pending_deletions ")
{
scans.push(format!("{name}: {step}"));
}
}
}
assert!(scans.is_empty(), "{scans:#?}");
Ok(())
}
-5
View File
@@ -961,10 +961,6 @@ mod tests {
.await;
assert!(context2.is_configured().await?);
assert_eq!(
context2.get_config(Config::ConfiguredAddr).await?,
Some("alice@example.org".to_string())
);
Ok(())
}
@@ -1074,7 +1070,6 @@ mod tests {
.await;
assert!(!context2.is_configured().await?);
assert_eq!(context2.get_config(Config::ConfiguredAddr).await?, None);
Ok(())
}
+4 -14
View File
@@ -124,15 +124,15 @@ pub trait DcKey: Serialize + Deserializable + Clone {
}
/// Converts secret key to public key.
///
/// `addr` is the address used in the User ID which is generated for compatibility.
/// `relay_addrs` is the comma-separated list of relay addresses.
pub(crate) fn secret_key_to_public_key(
context: &Context,
mut signed_secret_key: SignedSecretKey,
timestamp: u32,
addr: &str,
relay_addrs: &str,
) -> Result<SignedPublicKey> {
info!(context, "Converting secret key to public key.");
// Make sure timestamp of created signatures
// is not in the past compared to the primary key timestamp.
let timestamp = std::cmp::max(
@@ -305,7 +305,7 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
let addr = context.get_primary_self_addr().await?;
let all_addrs = context.get_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
secret_key_to_public_key(signed_secret_key, timestamp, &addr, &all_addrs)?;
*lock = Some(signed_public_key.clone());
Ok(Some(signed_public_key))
@@ -660,7 +660,6 @@ mod tests {
use std::time::Duration;
use super::*;
use crate::config::Config;
use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
use crate::tools::SystemTime;
use crate::transport::add_pseudo_transport;
@@ -814,9 +813,6 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
async fn test_load_self_generate_public() {
let t = TestContext::new().await;
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.unwrap();
let key = load_self_public_key(&t).await;
assert!(key.is_ok());
}
@@ -825,9 +821,6 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
async fn test_load_self_generate_secret() {
let t = TestContext::new().await;
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.unwrap();
let key = load_self_secret_key(&t).await;
assert!(key.is_ok());
}
@@ -838,9 +831,6 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
let t = TestContext::new().await;
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.unwrap();
let thr0 = {
let ctx = t.clone();
thread::spawn(move || {
+19 -7
View File
@@ -188,13 +188,25 @@ pub(crate) async fn maybe_send_keyupdate_message(context: &Context) -> Result<()
}
let recipients = keyupdate_recipients(context, KEYUPDATE_MAX_RECIPIENTS).await?;
for chunk in recipients.chunks(KEYUPDATE_CHUNK_CONTACTS) {
let envelope = envelope_recipients(chunk);
let rfc724_mid = create_outgoing_rfc724_mid();
let keys = chunk.iter().map(|r| r.public_key.clone()).collect();
let queued_msg = keyupdate_message(context, &rfc724_mid, keys, envelope).await?;
insert_into_smtp(context, &rfc724_mid, &queued_msg).await?;
}
info!(
context,
"Sending keyupdate messages to {} recipients.",
recipients.len()
);
let is_bot = context.get_config_bool(Config::Bot).await?;
context
.sql
.transaction(|tx| {
for chunk in recipients.chunks(KEYUPDATE_CHUNK_CONTACTS) {
let envelope = envelope_recipients(chunk);
let rfc724_mid = create_outgoing_rfc724_mid();
let keys = chunk.iter().map(|r| r.public_key.clone()).collect();
let queued_msg = keyupdate_message(is_bot, &rfc724_mid, keys, envelope);
insert_into_smtp(tx, &rfc724_mid, &queued_msg)?;
}
Ok(())
})
.await?;
// Record only after queueing, so failed queueing is retried by a later check.
context
-4
View File
@@ -260,10 +260,6 @@ async fn test_keyupdate_on_replacement() -> Result<()> {
alice.add_transport("alice@relay.example.net").await;
SystemTime::shift(Duration::from_secs(2));
alice.delete_transport("alice@example.org").await?;
assert_eq!(
alice.get_config(Config::ConfiguredAddr).await?.as_deref(),
Some("alice@relay.example.net")
);
maybe_send_keyupdate_message(alice).await?;
let keyupdate = alice.pop_sent_msg().await;
+101 -122
View File
@@ -10,6 +10,7 @@ use deltachat_derive::{FromSql, ToSql};
use humansize::BINARY;
use humansize::format_size;
use num_traits::FromPrimitive;
use rusqlite::{OptionalExtension, Transaction};
use serde::{Deserialize, Serialize};
use tokio::{fs, io};
@@ -22,9 +23,9 @@ use crate::contact::{self, Contact, ContactId};
use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
use crate::download::DownloadState;
use crate::ephemeral::{Timer as EphemeralTimer, start_ephemeral_timers_msgids};
use crate::ephemeral::Timer as EphemeralTimer;
use crate::events::EventType;
use crate::imap::markseen_on_imap_table;
use crate::imap::insert_imap_markseen;
use crate::location;
use crate::log::warn;
use crate::mimeparser::{SystemMessage, parse_message_id};
@@ -1777,87 +1778,69 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
.set_config_internal(Config::LastMsgId, Some(&last_msg_id.to_u32().to_string()))
.await?;
let mut msgs = Vec::with_capacity(msg_ids.len());
for &id in &msg_ids {
if let Some(msg) = context
.sql
.query_row_optional(
"SELECT
m.chat_id AS chat_id,
m.state AS state,
m.ephemeral_timer AS ephemeral_timer,
m.param AS param,
m.from_id AS from_id,
m.rfc724_mid AS rfc724_mid,
m.hidden AS hidden,
c.archived AS archived,
c.blocked AS blocked
FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id
WHERE m.id=? AND m.chat_id>9",
(id,),
|row| {
let chat_id: ChatId = row.get("chat_id")?;
let state: MessageState = row.get("state")?;
let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
let from_id: ContactId = row.get("from_id")?;
let rfc724_mid: String = row.get("rfc724_mid")?;
let hidden: bool = row.get("hidden")?;
let visibility: ChatVisibility = row.get("archived")?;
let blocked: Option<Blocked> = row.get("blocked")?;
let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
Ok((
(
id,
chat_id,
state,
param,
from_id,
rfc724_mid,
hidden,
visibility,
blocked.unwrap_or_default(),
),
ephemeral_timer,
))
},
)
.await?
{
msgs.push(msg);
}
}
if msgs
.iter()
.any(|(_, ephemeral_timer)| *ephemeral_timer != EphemeralTimer::Disabled)
{
start_ephemeral_timers_msgids(context, &msg_ids)
.await
.context("failed to start ephemeral timers")?;
}
let should_send_mdns = context.should_send_mdns().await?;
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
let mut updated_chat_ids = BTreeSet::new();
let mut archived_chats_maybe_noticed = false;
for (
(
id,
curr_chat_id,
curr_state,
curr_param,
curr_from_id,
curr_rfc724_mid,
curr_hidden,
curr_visibility,
curr_blocked,
),
_curr_ephemeral_timer,
) in msgs
{
if curr_state == MessageState::InFresh || curr_state == MessageState::InNoticed {
update_msg_state(context, id, MessageState::InSeen).await?;
let mut trigger_imap_interrupt = false;
let mut trigger_smtp_interrupt = false;
let mut trigger_ephemeral_interrupt = false;
let sql = &context.sql;
sql.transaction(|tx| {
for id in msg_ids {
let Some((
curr_chat_id,
curr_state,
curr_param,
curr_from_id,
curr_rfc724_mid,
curr_hidden,
curr_visibility,
curr_blocked,
)) = tx
.query_row(
"SELECT
m.chat_id AS chat_id,
m.state AS state,
m.param AS param,
m.from_id AS from_id,
m.rfc724_mid AS rfc724_mid,
m.hidden AS hidden,
c.archived AS archived,
c.blocked AS blocked
FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id
WHERE m.id=? AND m.chat_id>9",
(id,),
|row| {
let chat_id: ChatId = row.get("chat_id")?;
let state: MessageState = row.get("state")?;
let param: Params =
row.get::<_, String>("param")?.parse().unwrap_or_default();
let from_id: ContactId = row.get("from_id")?;
let rfc724_mid: String = row.get("rfc724_mid")?;
let hidden: bool = row.get("hidden")?;
let visibility: ChatVisibility = row.get("archived")?;
let blocked: Option<Blocked> = row.get("blocked")?;
let blocked = blocked.unwrap_or_default();
Ok((
chat_id, state, param, from_id, rfc724_mid, hidden, visibility,
blocked,
))
},
)
.optional()?
else {
continue;
};
trigger_ephemeral_interrupt |= id.start_ephemeral_timer(tx)?;
if !matches!(curr_state, MessageState::InFresh | MessageState::InNoticed) {
continue;
}
update_msg_state(tx, id, MessageState::InSeen)?;
info!(context, "Seen message {}.", id);
markseen_on_imap_table(context, &curr_rfc724_mid).await?;
insert_imap_markseen(tx, &curr_rfc724_mid)?;
trigger_imap_interrupt = true;
// Read receipts for system messages are never sent to contacts.
// These messages have no place to display received read receipt
@@ -1874,21 +1857,18 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
&& !curr_hidden
&& wants_mdn
&& curr_param.get_cmd() == SystemMessage::Unknown
&& context.should_send_mdns().await?
&& should_send_mdns
{
// Clear WantsMdn to not handle a MDN twice
// if the state later is InFresh again as markfresh_chat() was called.
// BccSelf MDN messages in the next branch may be sent twice for syncing.
context
.sql
.execute(
"UPDATE msgs SET param=? WHERE id=?",
(curr_param.clone().remove(Param::WantsMdn).to_string(), id),
)
.await
.context("failed to clear WantsMdn")?;
tx.execute(
"UPDATE msgs SET param=? WHERE id=?",
(curr_param.clone().remove(Param::WantsMdn).to_string(), id),
)
.context("failed to clear WantsMdn")?;
Some(curr_from_id)
} else if context.get_config_bool(Config::BccSelf).await? {
} else if bcc_self {
Some(ContactId::SELF)
} else {
None
@@ -1899,24 +1879,32 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
"Queuing MDN to {to_id} for {id} from {curr_from_id}, wants_mdn={wants_mdn}, cmd={}.",
curr_param.get_cmd()
);
context
.sql
.execute(
"INSERT INTO smtp_mdns (msg_id, from_id, rfc724_mid) VALUES(?, ?, ?)",
(id, to_id, curr_rfc724_mid),
)
.await
.context("failed to insert into smtp_mdns")?;
context.scheduler.interrupt_smtp().await;
tx.execute(
"INSERT INTO smtp_mdns (msg_id, from_id, rfc724_mid) VALUES(?, ?, ?)",
(id, to_id, curr_rfc724_mid),
)
.context("failed to insert into smtp_mdns")?;
trigger_smtp_interrupt = true;
}
if !curr_hidden {
updated_chat_ids.insert(curr_chat_id);
}
archived_chats_maybe_noticed |= curr_state == MessageState::InFresh
&& !curr_hidden
&& curr_visibility == ChatVisibility::Archived;
}
archived_chats_maybe_noticed |= curr_state == MessageState::InFresh
&& !curr_hidden
&& curr_visibility == ChatVisibility::Archived;
Ok(())
})
.await?;
if trigger_imap_interrupt {
context.scheduler.interrupt_inbox().await;
}
if trigger_smtp_interrupt {
context.scheduler.interrupt_smtp().await;
}
if trigger_ephemeral_interrupt {
context.scheduler.interrupt_ephemeral_task().await;
}
for updated_chat_id in updated_chat_ids {
@@ -1957,11 +1945,7 @@ pub async fn get_existing_msg_ids(context: &Context, ids: &[MsgId]) -> Result<Ve
Ok(res)
}
pub(crate) async fn update_msg_state(
context: &Context,
msg_id: MsgId,
state: MessageState,
) -> Result<()> {
pub(crate) fn update_msg_state(tx: &Transaction, msg_id: MsgId, state: MessageState) -> Result<()> {
ensure!(
state != MessageState::OutMdnRcvd,
"Update msgs_mdns table instead!"
@@ -1971,13 +1955,10 @@ pub(crate) async fn update_msg_state(
true => ", error=''",
false => "",
};
context
.sql
.execute(
&format!("UPDATE msgs SET state=? {error_subst} WHERE id=?"),
(state, msg_id),
)
.await?;
tx.execute(
&format!("UPDATE msgs SET state=? {error_subst} WHERE id=?"),
(state, msg_id),
)?;
Ok(())
}
@@ -2020,18 +2001,16 @@ pub(crate) async fn set_msg_failed(
/// With `on_server`, copies seen on IMAP later are deleted on the server.
///
/// Returns tombstone database row ID.
pub(crate) async fn insert_tombstone(
context: &Context,
pub(crate) fn insert_tombstone(
tx: &mut rusqlite::Transaction<'_>,
rfc724_mid: &str,
on_server: bool,
) -> Result<MsgId> {
let row_id = context
.sql
.insert(
"INSERT INTO msgs(rfc724_mid, chat_id, deleted) VALUES (?,?,?)",
(rfc724_mid, ChatId::TRASH, on_server),
)
.await?;
tx.execute(
"INSERT INTO msgs(rfc724_mid, chat_id, deleted) VALUES (?,?,?)",
(rfc724_mid, ChatId::TRASH, on_server),
)?;
let row_id = tx.last_insert_rowid();
let msg_id = MsgId::new(u32::try_from(row_id)?);
Ok(msg_id)
}
+18
View File
@@ -316,6 +316,24 @@ async fn test_markseen_msgs() -> Result<()> {
Ok(())
}
/// Tests that a read receipt failing to be queued is queued by the next `markseen_msgs()`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_msgs_failed_mdn_retried() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let msg = tcm.send_recv_accept(alice, bob, "hi").await;
let sql = &bob.sql;
sql.execute("ALTER TABLE smtp_mdns RENAME TO t", ()).await?;
assert!(markseen_msgs(bob, vec![msg.id]).await.is_err());
sql.execute("ALTER TABLE t RENAME TO smtp_mdns", ()).await?;
markseen_msgs(bob, vec![msg.id]).await?;
assert!(sql.exists("SELECT COUNT(*) FROM smtp_mdns", ()).await?);
Ok(())
}
/// Message has been seen on another device when fully downloaded. `state` should be updated.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
+27 -40
View File
@@ -347,16 +347,15 @@ pub(crate) fn render_queued_mail(
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
continue;
}
let header_value =
if header_name == "chat-version" || header_name == "chat-is-post-message" {
parsed_header.get_value_raw()
} else if header_name == "subject" {
&b"[...]"[..]
} else if header_name == "to" {
&b"\"hidden-recipients\": ;"[..]
} else {
continue;
};
let header_value = if header_name == "chat-is-post-message" {
parsed_header.get_value_raw()
} else if header_name == "subject" {
&b"[...]"[..]
} else if header_name == "to" {
&b"\"hidden-recipients\": ;"[..]
} else {
continue;
};
outer_headers.extend(original_header_name.as_bytes());
outer_headers.extend(b": ");
@@ -433,9 +432,9 @@ pub(crate) fn render_queued_mail(
#[cfg(test)]
pub(crate) async fn render_queued_mail_with_context(
queued_mail: QueuedMail,
context: &Context,
context: &crate::test_utils::TestContext,
) -> Result<RenderedEmail> {
let from_addr = context.get_primary_self_addr().await?;
let from_addr = context.sending_addr().await;
let public_key = crate::key::load_self_public_key(context).await?;
let secret_key = crate::key::load_self_secret_key(context).await?;
@@ -999,14 +998,10 @@ impl MimeFactory {
true => context.get_config(Config::Displayname).await?,
false => None,
};
let self_name = &match self_name {
Some(name) => name,
None => context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default(),
};
Ok(stock_str::subject_for_new_contact(context, self_name))
Ok(stock_str::subject_for_new_contact(
context,
self_name.as_deref().unwrap_or("..."),
))
}
pub fn recipients(&self) -> Vec<String> {
@@ -1228,7 +1223,7 @@ impl MimeFactory {
///
/// Used only for tests.
#[cfg(test)]
pub async fn render(self, context: &Context) -> Result<RenderedEmail> {
pub async fn render(self, context: &crate::test_utils::TestContext) -> Result<RenderedEmail> {
// Does not matter, we are not going to return the QueuedMail.
let bcc_self = false;
let (queued_mail, _side_effects) =
@@ -2174,10 +2169,7 @@ fn b_encode(value: &str) -> String {
/// Returns the headers to place into the encrypted part
/// of messages that are not part of a chat.
async fn non_chat_headers(
context: &Context,
subject: &str,
) -> Result<Vec<(&'static str, HeaderType<'static>)>> {
fn non_chat_headers(is_bot: bool, subject: &str) -> Vec<(&'static str, HeaderType<'static>)> {
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(time(), 0)
.unwrap()
.to_rfc2822();
@@ -2187,10 +2179,10 @@ async fn non_chat_headers(
("Subject", Text::new(subject.to_string()).into()),
];
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
if context.get_config_bool(Config::Bot).await? {
if is_bot {
headers.push(("Auto-Submitted", Raw::new("auto-generated").into()));
}
Ok(headers)
headers
}
pub(crate) async fn symm_encrypted_securejoin_message(
@@ -2206,7 +2198,8 @@ pub(crate) async fn symm_encrypted_securejoin_message(
let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join");
let mut headers = non_chat_headers(context, "Secure-Join").await?;
let is_bot = context.get_config_bool(Config::Bot).await?;
let mut headers = non_chat_headers(is_bot, "Secure-Join");
headers.push(("Secure-Join", Raw::new(step.to_string()).into()));
headers.push(("Secure-Join-Auth", Text::new(auth.to_string()).into()));
@@ -2384,23 +2377,18 @@ fn keyupdate_body() -> MimePart<'static> {
/// Returns a keyupdate message informing the owners of `recipient_keys`
/// about the current key and relay list, see [`crate::keyupdate`].
pub(crate) async fn keyupdate_message(
context: &Context,
pub(crate) fn keyupdate_message(
is_bot: bool,
rfc724_mid: &str,
recipient_keys: Vec<SignedPublicKey>,
recipients: Vec<String>,
) -> Result<QueuedMail> {
info!(
context,
"Sending keyupdate message to {} recipients.",
recipient_keys.len()
);
) -> QueuedMail {
let message = keyupdate_body();
let headers = non_chat_headers(context, "Keyupdate").await?;
let headers = non_chat_headers(is_bot, "Keyupdate");
let message = add_headers_to_encrypted_part(message, headers);
let queued_mail = QueuedMail {
QueuedMail {
raw_message: part_to_bytes(message),
display_name: String::new(),
rfc724_mid: rfc724_mid.to_string(),
@@ -2420,8 +2408,7 @@ pub(crate) async fn keyupdate_message(
recipients,
sent_to: Vec::new(),
bcc_self: false,
};
Ok(queued_mail)
}
}
/// Renders MIME part into a vector of bytes.
+9 -14
View File
@@ -185,7 +185,7 @@ async fn test_subject_outgoing() {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await.unwrap();
assert_eq!(first_subject_str(t).await, "Message from alice@example.org");
assert_eq!(first_subject_str(t).await, "Message from ...");
let t = TestContext::new_alice().await;
t.allow_unencrypted().await.unwrap();
@@ -362,7 +362,6 @@ async fn test_mdn_sent_to_all_relays() -> Result<()> {
// Bob's key gets a second relay address and Alice merges the newer key.
let bob_secret_key = load_self_secret_key(bob).await?;
let bob_public_key = secret_key_to_public_key(
bob,
bob_secret_key,
u32::try_from(time())? + 100,
"bob@example.net",
@@ -732,9 +731,9 @@ async fn test_remove_member_bcc() -> Result<()> {
let charlie = &tcm.charlie().await;
alice.allow_unencrypted().await?;
let alice_addr = alice.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_addr = bob.get_config(Config::ConfiguredAddr).await?.unwrap();
let charlie_addr = charlie.get_config(Config::ConfiguredAddr).await?.unwrap();
let alice_addr = alice.get_primary_self_addr().await?;
let bob_addr = bob.get_primary_self_addr().await?;
let charlie_addr = charlie.get_primary_self_addr().await?;
let bob_id = alice.add_or_lookup_address_contact_id(bob).await;
let charlie_id = alice.add_or_lookup_address_contact_id(charlie).await;
@@ -897,7 +896,7 @@ async fn test_new_member_is_first_recipient() -> Result<()> {
assert!(
sent_msg
.recipients
.starts_with(&charlie.get_config(Config::ConfiguredAddr).await?.unwrap())
.starts_with(&charlie.get_primary_self_addr().await?)
);
remove_contact_from_chat(alice, group, bob_id).await?;
@@ -905,11 +904,8 @@ async fn test_new_member_is_first_recipient() -> Result<()> {
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, group, bob_id).await?;
let sent_msg = alice.pop_sent_msg().await;
assert!(
sent_msg
.recipients
.starts_with(&bob.get_config(Config::ConfiguredAddr).await?.unwrap())
);
let bob_addr = bob.get_primary_self_addr().await?;
assert!(sent_msg.recipients.starts_with(&bob_addr));
Ok(())
}
@@ -1059,7 +1055,6 @@ Message-ID: <MESSAGE_ID@localhost>
MIME-Version: 1.0
To: "hidden-recipients": ;
Subject: [...]
Chat-Version: 1.0
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="BOUNDARY"
@@ -1117,7 +1112,7 @@ Autocrypt: addr=alice@example.org; prefer-encrypt=mutual;
Content-Type: text/plain; charset="utf-8"
Date: DATE
To: <bob@example.net>
Subject: Message from alice@example.org
Subject: Message from ...
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
Content-Transfer-Encoding: 7bit
@@ -1169,7 +1164,7 @@ Content-Type: multipart/mixed;
boundary="BOUNDARY"
Date: DATE
To: <bob@example.net>
Subject: Message from alice@example.org
Subject: Message from ...
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
+23 -24
View File
@@ -701,7 +701,6 @@ impl MimeMessage {
Ok(parser)
}
#[expect(clippy::arithmetic_side_effects)]
fn get_timestamp_sent(
hdrs: &[mailparse::MailHeader<'_>],
default: i64,
@@ -710,7 +709,10 @@ impl MimeMessage {
hdrs.get_header_value(HeaderDef::Date)
.and_then(|v| mailparse::dateparse(&v).ok())
.map_or(default, |value| {
min(value, timestamp_rcvd + constants::TIMESTAMP_SENT_TOLERANCE)
min(
value,
timestamp_rcvd.saturating_add(constants::TIMESTAMP_SENT_TOLERANCE),
)
})
}
@@ -970,7 +972,6 @@ impl MimeMessage {
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn avatar_action_from_header(
&mut self,
context: &Context,
@@ -990,23 +991,19 @@ impl MimeMessage {
None
}
}
} else if let Some(i) = self
.parts
.iter()
.position(|part| part.org_filename.as_ref() == Some(&header_value))
{
// Previous versions of Delta Chat sent avatars as attachments
// with the filename of the attachment sent in the Chat-User-Avatar header.
self.parts
.remove(i)
.param
.get(Param::File)
.map(|blob| AvatarAction::Change(blob.to_string()))
} else {
// Avatar sent in attachment, as previous versions of Delta Chat did.
let mut i = 0;
while let Some(part) = self.parts.get_mut(i) {
if let Some(part_filename) = &part.org_filename
&& part_filename == &header_value
{
if let Some(blob) = part.param.get(Param::File) {
let res = Some(AvatarAction::Change(blob.to_string()));
self.parts.remove(i);
return Ok(res);
}
break;
}
i += 1;
}
None
};
Ok(res)
@@ -1367,7 +1364,7 @@ impl MimeMessage {
self.do_add_single_part(part);
return Ok(true);
}
mime::TEXT | mime::HTML => {
mime::TEXT => {
let decoded_data = match mail.get_body() {
Ok(decoded_data) => decoded_data,
Err(err) => {
@@ -1490,7 +1487,6 @@ impl MimeMessage {
}
#[expect(clippy::too_many_arguments)]
#[expect(clippy::arithmetic_side_effects)]
async fn do_add_single_file_part(
&mut self,
context: &Context,
@@ -1565,7 +1561,11 @@ impl MimeMessage {
{
match get_filemeta(decoded_data) {
// image size is known, not too big, keep msg_type:
Ok((width, height)) if width * height <= constants::MAX_RCVD_IMAGE_PIXELS => {
Ok((width, height))
if width
.checked_mul(height)
.is_some_and(|pixels| pixels <= constants::MAX_RCVD_IMAGE_PIXELS) =>
{
part.param.set_i64(Param::Width, width.into());
part.param.set_i64(Param::Height, height.into());
msg_type
@@ -2045,9 +2045,8 @@ impl MimeMessage {
/// Returns parsed `Chat-Group-Member-Timestamps` header contents.
///
/// Returns `None` if there is no such header.
#[expect(clippy::arithmetic_side_effects)]
pub fn chat_group_member_timestamps(&self) -> Option<Vec<i64>> {
let now = time() + constants::TIMESTAMP_SENT_TOLERANCE;
let now = time().saturating_add(constants::TIMESTAMP_SENT_TOLERANCE);
self.get_header(HeaderDef::ChatGroupMemberTimestamps)
.map(|h| {
h.split_ascii_whitespace()
+24
View File
@@ -2228,3 +2228,27 @@ async fn test_load_shared_secrets_with_legacy_state() -> Result<()> {
Ok(())
}
/// Tests that MIME parts with html/* MIME type are ignored.
///
/// HTML parts should have a text/html MIME type.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ignore_html_mime_parts() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let encrypted_msg = test_utils::encrypt_raw_message(
bob,
&[alice],
include_bytes!("../../test-data/message/html_html_mime.eml"),
)
.await?;
let msg = MimeMessage::from_bytes(alice, encrypted_msg.as_bytes()).await?;
assert_eq!(msg.parts.len(), 1);
assert_eq!(msg.parts[0].typ, Viewtype::Text);
assert_eq!(msg.parts[0].mimetype, Some(mime::TEXT_PLAIN));
assert_eq!(msg.parts[0].msg, "Here is a text part.");
Ok(())
}
@@ -131,7 +131,7 @@ async fn test_broadcast_security_attacker_signature() -> Result<()> {
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
let charlie_addr = charlie.get_config(Config::ConfiguredAddr).await?.unwrap();
let charlie_addr = charlie.get_primary_self_addr().await?;
test_shared_secret_decryption_ext(
bob,
@@ -183,7 +183,7 @@ async fn test_broadcast_security_happy_path() -> Result<()> {
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
let alice_addr = alice.get_config(Config::ConfiguredAddr).await?.unwrap();
let alice_addr = alice.get_primary_self_addr().await?;
test_shared_secret_decryption_ext(bob, &alice_addr, &secret, Some(alice), None).await
}
@@ -202,7 +202,7 @@ async fn test_qr_code_security() -> Result<()> {
// Start a securejoin process, but don't finish it:
join_securejoin(bob, &qr).await?;
let charlie_addr = charlie.get_config(Config::ConfiguredAddr).await?.unwrap();
let charlie_addr = charlie.get_primary_self_addr().await?;
let alice_fp = self_fingerprint(alice).await?;
let secret_for_encryption = format!("securejoin/{alice_fp}/{authcode}");
+29 -16
View File
@@ -316,10 +316,6 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"mail.buzon.uy",
vec![IpAddr::V4(Ipv4Addr::new(200, 40, 115, 74))],
),
(
"daleth.cafe",
vec![IpAddr::V4(Ipv4Addr::new(37, 27, 6, 204))],
),
(
"disroot.org",
vec![IpAddr::V4(Ipv4Addr::new(178, 21, 23, 139))],
@@ -680,6 +676,7 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
],
),
// Known public chatmail relays from https://chatmail.at/relays
// and default onboarding relays.
(
"mehl.cloud",
vec![IpAddr::V4(Ipv4Addr::new(95, 217, 223, 172))],
@@ -688,6 +685,10 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"mailchat.pl",
vec![IpAddr::V4(Ipv4Addr::new(46, 62, 144, 137))],
),
(
"nchrcht.la10cy.net",
vec![IpAddr::V4(Ipv4Addr::new(217, 197, 86, 157))],
),
(
"chatmail.woodpeckersnest.space",
vec![IpAddr::V4(Ipv4Addr::new(85, 215, 162, 146))],
@@ -704,6 +705,10 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"chat.adminforge.de",
vec![IpAddr::V4(Ipv4Addr::new(94, 130, 17, 142))],
),
(
"chat.me.ke",
vec![IpAddr::V4(Ipv4Addr::new(169, 255, 58, 122))],
),
(
"chika.aangat.lahat.computer",
vec![IpAddr::V4(Ipv4Addr::new(71, 19, 150, 113))],
@@ -725,8 +730,20 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
vec![IpAddr::V4(Ipv4Addr::new(45, 124, 54, 79))],
),
(
"sombras.chat",
vec![IpAddr::V4(Ipv4Addr::new(82, 25, 70, 154))],
"chatmail.cc",
vec![IpAddr::V4(Ipv4Addr::new(15, 235, 198, 20))],
),
(
"deltachat.me",
vec![IpAddr::V4(Ipv4Addr::new(102, 68, 86, 134))],
),
(
"e2e.sus.fr",
vec![IpAddr::V4(Ipv4Addr::new(93, 95, 231, 235))],
),
(
"chatmail.uk",
vec![IpAddr::V4(Ipv4Addr::new(213, 165, 89, 146))],
),
(
"e2ee.wang",
@@ -736,7 +753,7 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"chat.privittytech.com",
vec![IpAddr::V4(Ipv4Addr::new(35, 154, 144, 0))],
),
("e2ee.im", vec![IpAddr::V4(Ipv4Addr::new(45, 137, 99, 57))]),
("e2ee.im", vec![IpAddr::V4(Ipv4Addr::new(45, 137, 99, 156))]),
(
"chatmail.email",
vec![IpAddr::V4(Ipv4Addr::new(57, 128, 220, 120))],
@@ -753,6 +770,10 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"chat.nuvon.app",
vec![IpAddr::V4(Ipv4Addr::new(178, 238, 38, 165))],
),
(
"chat.tinydispatch.org",
vec![IpAddr::V4(Ipv4Addr::new(88, 198, 181, 62))],
),
(
"nibblehole.com",
vec![IpAddr::V4(Ipv4Addr::new(94, 247, 42, 209))],
@@ -761,10 +782,6 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"chat.zashm.org",
vec![IpAddr::V4(Ipv4Addr::new(91, 245, 76, 39))],
),
(
"chat.sus.fr",
vec![IpAddr::V4(Ipv4Addr::new(152, 67, 76, 190))],
),
(
"delta.thelab.uno",
vec![IpAddr::V4(Ipv4Addr::new(146, 59, 228, 39))],
@@ -779,16 +796,12 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
),
(
"sweetfern.net",
vec![IpAddr::V4(Ipv4Addr::new(178, 156, 228, 133))],
vec![IpAddr::V4(Ipv4Addr::new(51, 81, 78, 167))],
),
(
"delta.disobey.net",
vec![IpAddr::V4(Ipv4Addr::new(37, 74, 102, 44))],
),
(
"darkrun.dev",
vec![IpAddr::V4(Ipv4Addr::new(72, 11, 149, 146))],
),
])
});
+1 -21
View File
@@ -23,7 +23,7 @@
//! (scoped per WebXDC app instance/message-id). The other peers can then join the gossip with `joinRealtimeChannel().setListener()`
//! and `joinRealtimeChannel().send()` just like the other peers.
use anyhow::{Context as _, Result, anyhow, bail};
use anyhow::{Context as _, Result, anyhow};
use data_encoding::BASE32_NOPAD;
use futures_lite::StreamExt;
use iroh::{Endpoint, NodeAddr, NodeId, PublicKey, RelayMode, RelayUrl, SecretKey};
@@ -38,7 +38,6 @@ use url::Url;
use crate::EventType;
use crate::chat::send_msg;
use crate::config::Config;
use crate::context::Context;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
@@ -299,10 +298,6 @@ impl Context {
pub async fn get_or_try_init_peer_channel(
&self,
) -> Result<tokio::sync::RwLockReadGuard<'_, Iroh>> {
if !self.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
bail!("Attempt to initialize Iroh when realtime is disabled");
}
if let Some(lock) = self.get_peer_channels().await {
return Ok(lock);
}
@@ -360,13 +355,6 @@ pub async fn add_gossip_peer_from_header(
instance_id: MsgId,
node_addr: &str,
) -> Result<()> {
if !context
.get_config_bool(Config::WebxdcRealtimeEnabled)
.await?
{
return Ok(());
}
let node_addr =
serde_json::from_str::<NodeAddr>(node_addr).context("Failed to parse node address")?;
@@ -464,10 +452,6 @@ pub async fn send_webxdc_realtime_advertisement(
ctx: &Context,
msg_id: MsgId,
) -> Result<Option<oneshot::Receiver<()>>> {
if !ctx.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
return Ok(None);
}
let iroh = ctx.get_or_try_init_peer_channel().await?;
let conn = iroh.join_and_subscribe_gossip(ctx, msg_id).await?;
@@ -483,10 +467,6 @@ pub async fn send_webxdc_realtime_advertisement(
/// Send realtime data to other peers using iroh.
pub async fn send_webxdc_realtime_data(ctx: &Context, msg_id: MsgId, data: Vec<u8>) -> Result<()> {
if !ctx.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
return Ok(());
}
let iroh = ctx.get_or_try_init_peer_channel().await?;
iroh.send_webxdc_realtime_data(ctx, msg_id, data).await?;
Ok(())
-44
View File
@@ -242,12 +242,6 @@ async fn test_can_reconnect() {
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
assert!(
alice
.get_config_bool(Config::WebxdcRealtimeEnabled)
.await
.unwrap()
);
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
@@ -588,49 +582,11 @@ async fn connect_alice_bob(
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_peer_channels_disabled() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
alice
.set_config_bool(Config::WebxdcRealtimeEnabled, false)
.await
.unwrap();
// creates iroh endpoint as side effect
send_webxdc_realtime_advertisement(alice, MsgId::new(1))
.await
.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
// creates iroh endpoint as side effect
send_webxdc_realtime_data(alice, MsgId::new(1), vec![])
.await
.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
leave_webxdc_realtime(alice, MsgId::new(1)).await.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
// This internal function should return error
// if accidentally called with the setting disabled.
assert!(alice.ctx.get_or_try_init_peer_channel().await.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_leave_webxdc_realtime_uninitialized() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
alice
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
leave_webxdc_realtime(alice, MsgId::new(1)).await.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
+68 -9
View File
@@ -1,5 +1,6 @@
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
@@ -83,13 +84,64 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
/// Selects a subkey of the public key to use for encryption.
///
/// Returns `None` if the public key cannot be used for encryption.
/// The key is selected according to
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-03.html#section-4.3-4>.
/// If multiple keys are available, the one that will expire sooner is selected.
///
/// TODO: take key flags and expiration dates into account
fn select_pk_for_encryption(key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
/// Returns `None` if the public key cannot be used for encryption.
fn select_pk_for_encryption(now: u32, key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
key.public_subkeys
.iter()
.find(|subkey| subkey.algorithm().can_encrypt())
.filter(|subkey| subkey.algorithm().can_encrypt())
.filter_map(|subkey| {
// We take arbitrary signature here and don't try to select the newest one.
// Filtering out outdated and invalid signatures should happen during certificate merging.
let signature = subkey.signatures.first()?;
let key_flags = signature.key_flags();
if !key_flags.encrypt_comms() {
return None;
}
if let Some(expiration_duration) = signature
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0)
&& now
> subkey
.created_at()
.as_secs()
.saturating_add(expiration_duration.as_secs())
{
// Key is expired.
return None;
}
Some((subkey, signature))
})
.min_by(|(subkey1, signature1), (subkey2, signature2)| {
match (
signature1
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0),
signature2
.key_expiration_time()
.filter(|duration| duration.as_secs() != 0),
) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(expiration1), Some(expiration2)) => (subkey1
.created_at()
.as_secs()
.saturating_add(expiration1.as_secs()))
.cmp(
&(subkey2
.created_at()
.as_secs()
.saturating_add(expiration2.as_secs())),
),
}
})
.map(|(subkey, _signature)| subkey)
}
/// Version of SEIPD packet to use.
@@ -110,12 +162,13 @@ pub enum SeipdVersion {
/// encrypted to `public_keys_for_encryption`.
#[expect(clippy::arithmetic_side_effects)]
fn signature_subpackets(
now: pgp::types::Timestamp,
private_key_for_signing: &SignedSecretKey,
public_keys_for_encryption: &[SignedPublicKey],
) -> Result<SubpacketConfig> {
let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1);
hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime(
pgp::types::Timestamp::now(),
now,
))?);
for key in public_keys_for_encryption {
let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint());
@@ -151,10 +204,11 @@ pub fn pk_encrypt(
) -> Result<String> {
tokio::task::block_in_place(|| {
let mut rng = thread_rng();
let now = pgp::types::Timestamp::now();
let pkeys = public_keys_for_encryption
.iter()
.filter_map(select_pk_for_encryption);
.filter_map(|key| select_pk_for_encryption(now.as_secs(), key));
let msg = MessageBuilder::from_bytes("", plain);
let encoded_msg = match seipd_version {
@@ -166,7 +220,8 @@ pub fn pk_encrypt(
}
if let Some(secret_key) = private_key_for_signing {
let subpkts = signature_subpackets(secret_key, &public_keys_for_encryption)?;
let subpkts =
signature_subpackets(now, secret_key, &public_keys_for_encryption)?;
let hash_algorithm = secret_key.hash_alg();
msg.sign_with_subpackets(
&**secret_key,
@@ -194,7 +249,8 @@ pub fn pk_encrypt(
}
if let Some(secret_key) = private_key_for_signing {
let subpkts = signature_subpackets(secret_key, &public_keys_for_encryption)?;
let subpkts =
signature_subpackets(now, secret_key, &public_keys_for_encryption)?;
let hash_algorithm = secret_key.hash_alg();
msg.sign_with_subpackets(
&**secret_key,
@@ -326,6 +382,8 @@ pub fn symm_encrypt_message(
/// or even have some packets maliciously dropped
/// (for example, all encryption subkeys dropped)
/// or restored from some older version of the certificate.
///
/// In case of ties, i.e. same timestamps, data from the new certificate is preferred.
pub fn merge_openpgp_certificates(
old_certificate: SignedPublicKey,
new_certificate: SignedPublicKey,
@@ -485,7 +543,8 @@ pub(crate) fn relay_addrs(public_key: &SignedPublicKey, addr: &str) -> Vec<Strin
/// Returns true if the key can be encrypted to, i.e. has an encryption subkey.
pub(crate) fn pubkey_can_encrypt(public_key: &SignedPublicKey) -> bool {
select_pk_for_encryption(public_key).is_some()
let now = pgp::types::Timestamp::now();
select_pk_for_encryption(now.as_secs(), public_key).is_some()
}
/// Returns true if public key advertises SEIPDv2 feature.
+236 -4
View File
@@ -2,15 +2,16 @@ use std::sync::LazyLock;
use tokio::sync::OnceCell;
use super::*;
use crate::key::secret_key_to_public_key;
use crate::transport::add_pseudo_transport;
use crate::{
config::Config,
decrypt,
key::{load_self_public_key, self_fingerprint, store_self_keypair},
mimefactory::{part_to_bytes, wrap_encrypted_part},
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair},
token,
};
use chrono::{TimeZone as _, Utc};
use pgp::composed::{Esk, Message};
use pgp::packet::PublicKeyEncryptedSessionKey;
@@ -23,9 +24,6 @@ async fn decrypt_bytes(
add_pseudo_transport(t, "alice@example.org")
.await
.expect("Failed to add pseudo transport");
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.expect("Failed to configure address");
for secret in auth_tokens_for_decryption {
token::save(t, token::Namespace::Auth, None, secret, 0).await?;
@@ -387,6 +385,31 @@ fn test_merge_openpgp_certificates() {
assert!(merge_openpgp_certificates(bob, alice).is_err());
}
/// Tests that Direct Key Signature from the new certificate is preferred
/// when the timestamp is the same.
#[test]
fn test_merge_openpgp_certificates_tiebreak() -> Result<()> {
let secret_key = alice_keypair();
let timestamp = pgp::types::Timestamp::now();
let addr = "alice@example.net";
let relays1 = "alice@example.net,alice@example.org";
let relays2 = "alice@example.net,alice@example.com";
let public_key1 =
secret_key_to_public_key(secret_key.clone(), timestamp.as_secs(), addr, relays1)?;
let public_key2 = secret_key_to_public_key(secret_key, timestamp.as_secs(), addr, relays2)?;
let merge12 = merge_openpgp_certificates(public_key1.clone(), public_key2.clone())?;
let merge21 = merge_openpgp_certificates(public_key2.clone(), public_key1.clone())?;
assert_eq!(merge12, public_key2);
assert_eq!(merge21, public_key1);
assert_ne!(merge12, merge21);
Ok(())
}
/// Test PQC support.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pqc() -> Result<()> {
@@ -423,3 +446,212 @@ async fn test_securejoin_pqc_joiner() {
tcm.execute_securejoin(bob, pqc).await;
}
/// Tests that public subkey selection for encryption respects key flags and prefers expring subkeys.
///
/// Non-encryption subkeys such as RSA subkey for authentication are ignored.
#[test]
fn test_select_pk_for_encryption() {
// Public key generated with GnuPG 2.4.9 with the following subkeys:
// 1. Auth-only RSA subkey (92E762B9084CA740).
// 2. Expired Curve25519 encryption subkey with 1-day expiration (C8F382BD0F35C49E)
// 3. Ed25519 signing subkey (F177AC3118F923CC).
// 4. Curve25519 encryption subkey with fingerprint (36188C6FFC8E267B)
// 5. Curve25519 encryption subkey with 1 year expiration, valid in the beginning of 2008, with key ID 9223FCEE7546CDE7
// 6. Curve25519 encryption subkey with no expiration (FD2C0567967223D8).
// Primary key is an Ed25519 not expiring key.
// Key 4 is the one that should be selected.
// Subkey 4 fingerprint.
let expected_fallback_fingerprint = "cdeb3ba3999bf7880f0ee1f536188c6ffc8e267b";
// Subkey 5 fingerprint, should be preferred to fallback when not expired.
let expected_expiring_fingerprint = "5133fab157c4a46ca41f6dc39223fcee7546cde7";
let alice_tpk_asc = "
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMERvfcPBYJKwYBBAHaRw8BAQdAimvPsr7NdJ4dBoFPySwhpTQqoYOoHL3AzfE7
mGWQOOC0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBCi19Yqv
ugVVkhyHgicqAms0FFoiBQJG99w8AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
AAoJECcqAms0FFoiUGEA/3VZMBCoRq0ZpHarzmvzgdZCoL3r3m9en/eZScFzxITx
AP42Mn27r0SOKwIln0VcPTdAQCk49mBW/EX3CMOlLPU3DLkBjQRG99w8AQwArsYe
Jkdl6sSM/hfoEw0vgx/RdUBQ6QRYi1uc0UUNlIGy8mlczLFdkD3JF/hGocjPvt45
XAQoK110zAkZlfpFRqNT1M/IC68Er8rLkYPC4OeFh6W4Iyn17fcUanP0lf8em/jh
Vffvgy8sFOMdO235lvFA3txNA98s4fHdmU3PScyd1hc3C4M0yP83LnYyWt4X59Xc
E/Om5Dm458eKCSeYkLI6752W0mXsBxSi3/dLn0XeuNRpgmKxSkm562FHOFaLbKtR
Y5hobAI9PkNcVgRxZZvWQls5PHTZWjqngF21lKlaLfdqZ/Uae1i2hzZOihgitL43
Le00qwNBi5hKYMeuDnHaQrLmb+A+0/IAEE4Ub+TkZhzI+2ZP2k1cAT0qZmZi0w+q
xqP9INk0hY/oZFCnV2wkHN7zvQmVlUIcQ2rmfbafK1yiEL1qeGT96zyjbdXeGPqJ
3O9h+YIG38JMRJBijsFujUN34Z546zS/kzOPXsz/WlGUMjwu8n5s5uf2TFyFABEB
AAGIeAQYFgoAIBYhBCi19YqvugVVkhyHgicqAms0FFoiBQJG99w8AhsgAAoJECcq
Ams0FFoiCOUBAPafRLDpWN9iT4hcCXjESf1Hw5KNVkJpwfzPfu2H9BkMAQCaHhKg
pq9ywH4pyOHZCPV8P2ywkyn+EsjBC3fG+GBBBbg4BEb33DwSCisGAQQBl1UBBQEB
B0DfI8AJFT3nWa6ZXLkHSf7W8W7S6AWIO7LAcjoyHwb8CwMBCAeIfgQYFgoAJhYh
BCi19YqvugVVkhyHgicqAms0FFoiBQJG99w8AhsMBQkAAVGAAAoJECcqAms0FFoi
U5EA/3G74HRwIMJlNOEW5gkYYV5KJW2qgtMfxHCUjoHvNWU1AQCHt/bLU2aviAiS
of1R43qojxKUqzzoi8lYRQ+1sYhvB7gzBEb33DwWCSsGAQQB2kcPAQEHQKZXUJ7s
xqH3kVcMnhasw6DrFMwCxHDdj+qvkg8r/DvtiO8EGBYKACAWIQQotfWKr7oFVZIc
h4InKgJrNBRaIgUCRvfcPAIbAgCBCRAnKgJrNBRaInYgBBkWCgAdFiEEI8hstnVQ
9sgylIYg8XesMRj5I8wFAkb33DwACgkQ8XesMRj5I8xo6QEAu4o/TyEZwFcyqZpw
LEo9vTLCsc7fo0nx0ssiP6FyV5cBAOWal1DznDhsXWCNt+U8UaafXsU2DTV51KaD
VBOVFo4CyeQBAMirIjXV5PbUV674TNLhYl2s0jTtNz+GKtOjSdZuRm1mAPwPG6ya
K1b7iMRdBT92gNZMw30LbtcXmttCxpZAwr9lBLg4BEb33DwSCisGAQQBl1UBBQEB
B0C5fFb4WTHoIoI6ou/31+1N1wn8ghsSkUVzpbtv/aTkegMBCAeIeAQYFgoAIBYh
BCi19YqvugVVkhyHgicqAms0FFoiBQJG99w8AhsMAAoJECcqAms0FFoi8z8BALPL
7V0ICLEY5YSUa4lQ2rjiXOcVTlWkG3h4TATPrr08AP9tIAQIE0o50IGdQAcKJoTn
Lyxnf2wfjZ16vL3JLLjSBrg4BEb33DwSCisGAQQBl1UBBQEBB0DXDrcGrnuLjAUO
eo/t8MQNOe+ZKYSDPGTkO7iM5IloSAMBCAeIfgQYFgoAJhYhBCi19YqvugVVkhyH
gicqAms0FFoiBQJG99w8AhsMBQkB4TOAAAoJECcqAms0FFoivQIA/2PcZ1vcImAa
7ldPY00JkcW6WlSSd6yOIZsVa4TdA1FiAP42gOji+4RrLps2+NX6L1znSc8EJBXo
RMbND/CZQWXfA7g4BEb33DwSCisGAQQBl1UBBQEBB0BHxCvo5zuygw2XiluYNobx
7iFJqlmCkjekKyoVFquKHQMBCAeIeAQYFgoAIBYhBCi19YqvugVVkhyHgicqAms0
FFoiBQJG99w8AhsMAAoJECcqAms0FFoirSMA/0gVP98sPFga+UhQ3uJxJw5bO2Rs
7hxVk6aPREWgBYg1AQCT7AE8m7j17SP/1fl8OjpxsQQmCJyv2wNcP48OfKGOCA==
=AXAi
-----END PGP PUBLIC KEY BLOCK-----
";
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
let now = pgp::types::Timestamp::now();
let encryption_subkey = select_pk_for_encryption(now.as_secs(), &alice_tpk).unwrap();
assert_eq!(
encryption_subkey.fingerprint().to_string().as_str(),
expected_fallback_fingerprint
);
// If we pass 1199149200 as the `now` argument, which was in the beginning of 2008,
// then the expiring subkey is not yet expired and should be used.
let encryption_subkey = select_pk_for_encryption(1199149200, &alice_tpk).unwrap();
assert_eq!(
encryption_subkey.fingerprint().to_string().as_str(),
expected_expiring_fingerprint
);
}
/// Tests that key selection fails if there is only an expired subkey.
#[test]
fn test_select_only_expired_subkey() {
let alice_tpk_asc = "
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMERvfcPBYJKwYBBAHaRw8BAQdAUz6AZXIRE8T04Vh8RReFiP3tEV8UfSs2EiYs
8b8i4ce0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBNRG3w/A
qWyPitBaqkGLyqDx3CaUBQJG99w8AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
AAoJEEGLyqDx3CaUMjIA/Rq9/iORLP360s6EsIDe9qSmlmSCggtivafH+uVBWa0X
AP0Wb+kailDwISq2O9Ef/jceZw7ozyzDLeDskKpCYiL3A7g4BEb33DwSCisGAQQB
l1UBBQEBB0BXhbrks9iskW1GfT3B022W3KhJCgz8gw81z9lAWv7OZgMBCAeIfgQY
FgoAJhYhBNRG3w/AqWyPitBaqkGLyqDx3CaUBQJG99w8AhsMBQkB4TOAAAoJEEGL
yqDx3CaU6/YA/jaYJsZMXvu5grrMq1wq3Z/yzGXd15zeWp+alY/6UYxoAQCmSrBC
SOtE3ODLZN9tC3F7k1N9clme1cHXyUiH3EliBQ==
=zWPq
-----END PGP PUBLIC KEY BLOCK-----
";
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
let now = pgp::types::Timestamp::now();
assert_eq!(select_pk_for_encryption(now.as_secs(), &alice_tpk), None);
}
/// Tests that the key with the closest expiration date is selected.
#[test]
fn test_expiring_subkey_selection() {
// The key has the following keys:
// 1. Primary non-expiring Ed25519 key created on 2025-01-01
// 2. Non-expiring subkey CE5FC3FD479E41F08069DCE6F6CAD4ADB9AF47F1 created on 2025-01-01
// 3. Subkey 10493EF4DBA7EE4D55C826A7634684D9BFEEB890 created on 2026-06-01 and expiring on 2026-07-01
// 4. Subkey 477650C13FA4842BAF2831BC41C2D560BE27B38D created on 2026-06-01 and expiring on 2026-06-15
// 5. Subkey AB92F4AFD46DDD7D31FB05151BE08A8089F67CA2 created on 2026-06-08 and expiring on 2026-06-22
let alice_tpk_asc = "
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZ3SFgBYJKwYBBAHaRw8BAQdAOrjYsxkYNvVtjbZwvglXk94Rd8S3F0YlIgcQ
SSp45ni0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFgoAOBYhBGV3iWZU
bA6WJh3MR00wvB0X57PiBQJndIWAAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
AAoJEE0wvB0X57PiAr0A/0qzulPRXZR1+D1fUeW6/C3BRP+8qRZTlOvF0XEnxPOV
AQD7oePZ/QI6AuDMFmnJ5HQJ6cQSSOp/FwyucD8yiAoABrg4BGd0hYASCisGAQQB
l1UBBQEBB0DBBk+BdzyTu3Hys6dewzd0L5AFeYMRIOC99XosMBLZYwMBCAeIeAQY
FgoAIBYhBGV3iWZUbA6WJh3MR00wvB0X57PiBQJndIWAAhsMAAoJEE0wvB0X57Pi
BxYA+gOtDVtb9p9vVaKyAuYCG7LdJ63Bqu0OtrVxmZEQvHQbAQDJmJX/yOX5QSkh
cRtM1qoiY7OUjcT8ERnaoAcw2suFBLg4BGocy4ASCisGAQQBl1UBBQEBB0AgEDSA
muuqS2iyi7aZeV608Xx2qdVqYKjREgiC3cu4YQMBCAeIfgQYFgoAJhYhBGV3iWZU
bA6WJh3MR00wvB0X57PiBQJqHMuAAhsMBQkAJ40AAAoJEE0wvB0X57Pi9KgA/2vW
4yNU1EF/WRCKmr87mJtmYR5Xf9Jps54Tv4BexNGgAQDxxx9lKvwW4U9/8UkrSF28
HrGdWES4wAa9N9HvJzuOD7g4BGocy4ASCisGAQQBl1UBBQEBB0CK1TifDqKtFzks
4E8gNmF90OkIYVriZxdY3QFSwowILQMBCAeIfgQYFgoAJhYhBGV3iWZUbA6WJh3M
R00wvB0X57PiBQJqHMuAAhsMBQkAEnUAAAoJEE0wvB0X57PiZ+UA/0+s3fM/8OOb
8Ft21QMFM/7Ce1P/ovKgkxsYWjk8q7zYAP9HYm/vcWBGzQw08mn3X26UFB9UfCF3
qc1yyltDEeERDrg4BGomBgASCisGAQQBl1UBBQEBB0AvGvgG8Wkwx/KklkUHdLuC
UKKSqTp4xsjocWXJs69rSwMBCAeIfgQYFgoAJhYhBGV3iWZUbA6WJh3MR00wvB0X
57PiBQJqJgYAAhsMBQkAEnUAAAoJEE0wvB0X57PibzQA/A0qT23pcdenNJJ5QZN/
ecs08p2pbiipv+adPeojVGZIAP47Pyr8Hj0o3qhrEhhFZkIhTxxDxS/jAsq2v4UG
JGp8Ag==
=RzZZ
-----END PGP PUBLIC KEY BLOCK-----
";
let alice_tpk = SignedPublicKey::from_asc(alice_tpk_asc).unwrap();
// Two-week subkey that was generated earlier is selected
// because it expires earlier, on 2026-06-15.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 6, 9, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"477650c13fa4842baf2831bc41c2d560be27b38d",
);
}
// Now both 477650C13FA4842BAF2831BC41C2D560BE27B38D expires
// and we switch to another subkey that expires on 2026-06-22.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 6, 16, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"ab92f4afd46ddd7d31fb05151be08a8089f67ca2",
);
}
// Now both two-week subkeys have expired and we switch to expiring subkey that expires on 2026-07-01.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 6, 23, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"10493ef4dba7ee4d55c826a7634684d9bfeeb890",
);
}
// All expiring subkeys are expired, switching to non-expiring subkey.
{
let ts = u32::try_from(
Utc.with_ymd_and_hms(2026, 7, 2, 0, 0, 0)
.unwrap()
.timestamp(),
)
.unwrap();
let subkey = select_pk_for_encryption(ts, &alice_tpk).unwrap();
assert_eq!(
subkey.fingerprint().to_string().as_str(),
"ce5fc3fd479e41f08069dce6f6cad4adb9af47f1",
);
}
}
+4 -4
View File
@@ -5,8 +5,9 @@
//!
//! Pinned messages are synchronized for all chat members by sending an info-message
//! that refers the pinned message in the `In-Reply-To:` header.
//! The info-message is only shown for pinning (not for unpinning).
//! Unpinning is an action that does not require so much attention; internally it is a hidden info-message.
//! Info-message are shown for both, pinning and unpinning;
//! the latter is important as otherwise the message "Alice pinned a message." may look like a bug
//! if there is nothing actually pinned as Bob unpinned for whatever reason afterwards.
use anyhow::{Result, ensure};
@@ -50,9 +51,8 @@ pub async fn set_pinned_state(
info_msg.text = if new_pinned_state {
stock_str::msg_pinned(context, ContactId::SELF).await
} else {
"Message unpinned.".to_string() // no need to localize, "unpinned" messages are not visible
stock_str::msg_unpinned(context, ContactId::SELF).await
};
info_msg.hidden = !new_pinned_state;
info_msg.in_reply_to = Some(msg.rfc724_mid.clone());
info_msg.param.set_cmd(if new_pinned_state {
SystemMessage::MessagePinned
+17 -22
View File
@@ -76,21 +76,20 @@ async fn test_pinned_messages() -> Result<()> {
assert!(set_pinned_state(alice, info_msg.id, true).await.is_err());
// Unpin the initially pinned message.
// Before, send another message "Bar". To test, no visible info message is added this time,
let sent3 = alice.send_text(alice_chat_id, "Bar").await;
set_pinned_state(alice, msg1.id, false).await?;
let sent4 = alice.pop_sent_msg().await;
let sent3 = alice.pop_sent_msg().await;
assert!(!sent1.load_from_db().await.is_pinned());
expect_pinned_message_event(alice, msg1.chat_id).await;
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert!(pinned.is_empty());
let msg3 = sent3.load_from_db().await;
assert!(!msg3.is_info());
assert!(!msg3.is_pinned());
assert_eq!(alice.get_last_msg_id_in(msg3.chat_id).await, msg3.id); // last message is still "Bar", not an info message
let unpin_msg = sent3.load_from_db().await;
assert!(unpin_msg.is_info());
assert!(!unpin_msg.hidden);
assert_eq!(unpin_msg.get_info_type(), SystemMessage::MessageUnpinned);
assert_eq!(unpin_msg.text, "You unpinned a message.");
assert_eq!(alice.get_last_msg_id_in(msg1.chat_id).await, unpin_msg.id);
if alice_chat_id != self_chat_id {
// Bob receives message "Foo"
@@ -115,19 +114,19 @@ async fn test_pinned_messages() -> Result<()> {
assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned);
assert!(info_msg.get_info_contact_id(bob).await?.is_none());
// Bob receives message "Bar" and hidden message to unpin message "Foo"
bob.recv_msg(&sent3).await;
bob.recv_msg_trash(&sent4).await;
// Bob receives the info message to unpin message "Foo"
let unpin_msg = bob.recv_msg(&sent3).await;
expect_pinned_message_event(bob, msg1.chat_id).await;
assert!(!Message::load_from_db(bob, msg1.id).await?.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert!(pinned.is_empty());
let no_info_msg =
Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(!no_info_msg.is_info());
assert_eq!(no_info_msg.text, "Bar");
assert!(unpin_msg.is_info());
assert!(!unpin_msg.hidden);
assert_eq!(unpin_msg.get_info_type(), SystemMessage::MessageUnpinned);
assert_eq!(unpin_msg.state, MessageState::InNoticed); // no notification, no badge
assert_eq!(bob.get_last_msg_id_in(msg1.chat_id).await, unpin_msg.id);
}
// Alice's second device receives all four messages and ends up in the same state
@@ -136,15 +135,11 @@ async fn test_pinned_messages() -> Result<()> {
expect_pinned_message_event(alice2, msg1.chat_id).await;
assert!(Message::load_from_db(alice2, msg1.id).await?.is_pinned());
alice2.recv_msg(&sent3).await;
alice2.recv_msg_trash(&sent4).await;
let unpin_msg = alice2.recv_msg(&sent3).await;
expect_pinned_message_event(alice2, msg1.chat_id).await;
assert!(!Message::load_from_db(alice2, msg1.id).await?.is_pinned());
let no_info_msg =
Message::load_from_db(alice2, alice2.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(!no_info_msg.is_info());
assert_eq!(no_info_msg.text, "Bar");
assert_eq!(unpin_msg.get_info_type(), SystemMessage::MessageUnpinned);
assert_eq!(unpin_msg.text, "You unpinned a message.");
}
Ok(())
-8
View File
@@ -156,14 +156,6 @@ mod tests {
};
assert!(!t.quota_needs_update(0, TIMEOUT).await);
t.evtracker.clear_events();
t.set_primary_self_addr("new@addr").await?;
assert!(t.quota.read().await.is_empty());
t.evtracker
.get_matching(|evt| matches!(evt, EventType::ConnectivityChanged))
.await;
assert!(t.quota_needs_update(0, TIMEOUT).await);
Ok(())
}
}
+93 -1
View File
@@ -354,9 +354,12 @@ pub(crate) fn refine_frequencies(
mod tests {
use super::*;
use crate::chat::create_broadcast;
use crate::chatlist::Chatlist;
use crate::reaction::send_reaction;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::TestContextManager;
use crate::test_utils::{TestContextManager, sync};
use crate::tools::SystemTime;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_reaction_wire_format() {
@@ -583,6 +586,34 @@ mod tests {
assert_eq!(result[1].is_from_self, true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_reactions_not_in_chatlist_summary() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
bob_chat_id.accept(bob).await?;
let sent_msg = alice.send_text(alice_chat_id, "hi channel!").await;
let bob_msg = bob.recv_msg(&sent_msg).await;
// reactions show up in the summary only if newer than the message; make sure that is the case.
SystemTime::shift(Duration::from_secs(10));
send_reaction(bob, bob_msg.id, "❤️").await?;
alice.recv_msg_hidden(&bob.pop_sent_msg().await).await;
for t in [alice, bob] {
let chatlist = Chatlist::try_load(t, 0, None, None).await?;
let summary = chatlist.get_summary(t, 0, None).await?;
assert_eq!(summary.text, "hi channel!");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_channel_reaction() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -721,6 +752,67 @@ mod tests {
Ok(())
}
/// The channel owner has two devices.
/// A reaction summary sent by one device must not make the other device
/// broadcast the summary again, otherwise the devices send summaries to each other
/// (and to all subscribers) every `REACTION_BROADCAST_PERIOD` forever.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_reactions_multidevice_no_loop() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice1 = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
for a in [alice1, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
let alice1_chat_id = create_broadcast(alice1, "Channel".to_string()).await?;
sync(alice1, alice2).await;
let qr = get_securejoin_qr(alice1, Some(alice1_chat_id)).await?;
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice1, &qr).await;
bob_chat_id.accept(bob).await?;
tcm.section("Alice1 posts, Alice2 and Bob receive the post");
let sent_msg = alice1.send_text(alice1_chat_id, "hi channel!").await;
let alice1_msg_id = sent_msg.load_from_db().await.id;
let alice2_msg = alice2.recv_msg(&sent_msg).await;
let bob_msg = bob.recv_msg(&sent_msg).await;
tcm.section("Alice2 reacts to her own post, Alice1 learns about it");
send_reaction(alice2, alice2_msg.id, "👍").await?;
alice1.recv_msg_hidden(&alice2.pop_sent_msg().await).await;
assert_eq!(
get_msg_reactions(alice1, alice1_msg_id).await?.to_string(),
"👍1"
);
broadcast_reactions_for_all_chats(alice2).await?; // bypass timer
let summary = alice2.pop_sent_msg().await;
alice1.recv_msg_hidden(&summary).await;
bob.recv_msg_hidden(&summary).await;
tcm.section("Bob reacts, Alice1 broadcasts the summary");
send_reaction(bob, bob_msg.id, "❤️").await?;
alice1.recv_msg_hidden(&bob.pop_sent_msg().await).await;
broadcast_reactions_for_all_chats(alice1).await?; // bypass timer
let summary = alice1.pop_sent_msg().await;
bob.recv_msg_hidden(&summary).await;
assert_eq!(
get_msg_reactions(bob, bob_msg.id).await?.to_string(),
"❤️1 👍1"
);
tcm.section("Alice2 receives Alice1's summary and must not broadcast it again");
alice2.recv_msg_hidden(&summary).await;
broadcast_reactions_for_all_chats(alice2).await?;
assert!(alice2.pop_sent_msg_opt().await.is_none());
tcm.section("The summary must not remove Alice's own reaction on Alice2");
let reactions = get_msg_reactions(alice2, alice2_msg.id).await?;
assert_eq!(reactions.to_string(), "👍1");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_subscriber_sends_unallowed_reaction() -> Result<()> {
let mut tcm = TestContextManager::new();
+38 -20
View File
@@ -483,7 +483,12 @@ pub(crate) async fn receive_imf_inner(
}
let trash = || async {
let msg_ids = vec![insert_tombstone(context, rfc724_mid, false).await?];
let msg_ids = vec![
context
.sql
.transaction(|tx| insert_tombstone(tx, rfc724_mid, false))
.await?,
];
Ok(Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::Undefined,
@@ -669,7 +674,10 @@ pub(crate) async fn receive_imf_inner(
match res {
securejoin::HandshakeMessage::Done | securejoin::HandshakeMessage::Ignore => {
let needs_delete_job = res == securejoin::HandshakeMessage::Done;
let msg_id = insert_tombstone(context, rfc724_mid, needs_delete_job).await?;
let msg_id = context
.sql
.transaction(|tx| insert_tombstone(tx, rfc724_mid, needs_delete_job))
.await?;
received_msg = Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::InSeen,
@@ -933,9 +941,15 @@ pub(crate) async fn receive_imf_inner(
warn!(context, "MDN: Not encrypted. Ignoring.");
continue;
}
message::update_msg_state(context, msg_id, MessageState::InSeen).await?;
if let Err(e) = msg_id.start_ephemeral_timer(context).await {
error!(context, "start_ephemeral_timer for {msg_id}: {e:#}.");
let trigger_ephemeral_interrupt = context
.sql
.transaction(|tx| {
message::update_msg_state(tx, msg_id, MessageState::InSeen)?;
msg_id.start_ephemeral_timer(tx)
})
.await?;
if trigger_ephemeral_interrupt {
context.scheduler.interrupt_ephemeral_task().await;
}
if !mime_parser.has_chat_version() {
continue;
@@ -1153,9 +1167,6 @@ async fn decide_chat_assignment(
{
info!(context, "Call state changed (TRASH).");
true
} else if mime_parser.is_system_message == SystemMessage::MessageUnpinned {
info!(context, "Message unpinned (TRASH).");
true
} else if let Some(ref decryption_error) = mime_parser.decryption_error
&& !mime_parser.incoming
{
@@ -1828,13 +1839,17 @@ async fn add_parts(
EphemeralTimer::Disabled
};
// silent messages are added as "noticed" and do not bubble up in the chatlist
let silent =
group_changes.silent || mime_parser.is_system_message == SystemMessage::MessageUnpinned;
let state = if !mime_parser.incoming {
MessageState::OutDelivered
} else if seen || !mime_parser.mdn_reports.is_empty() || chat_id_blocked == Blocked::Yes
// No check for `hidden` because only reactions are such and they should be `InFresh`.
{
MessageState::InSeen
} else if mime_parser.from.addr == STATISTICS_BOT_EMAIL || group_changes.silent {
} else if mime_parser.from.addr == STATISTICS_BOT_EMAIL || silent {
MessageState::InNoticed
} else {
MessageState::InFresh
@@ -1944,21 +1959,17 @@ async fn add_parts(
Some(better_msg)
} else if mime_parser.is_system_message == SystemMessage::MessagePinned {
Some(stock_str::msg_pinned(context, from_id).await) // message unpinned info is trashed in decide_chat_assignment()
Some(stock_str::msg_pinned(context, from_id).await)
} else if mime_parser.is_system_message == SystemMessage::MessageUnpinned {
Some(stock_str::msg_unpinned(context, from_id).await)
} else {
None
};
drop(chat); // Avoid using stale `chat` object.
let sort_timestamp = tweak_sort_timestamp(
context,
mime_parser,
group_changes.silent,
chat_id,
sort_timestamp,
)
.await?;
let sort_timestamp =
tweak_sort_timestamp(context, mime_parser, silent, chat_id, sort_timestamp).await?;
let mime_in_reply_to = mime_parser
.get_header(HeaderDef::InReplyTo)
@@ -2106,7 +2117,11 @@ async fn add_parts(
let mut parts = mime_parser.parts.iter().peekable();
while let Some(part) = parts.next() {
let hidden = part.is_reaction;
if part.is_reaction {
// A reaction summary of a broadcast channel (`Chat-Broadcast-States:`) is sent as an empty
// reaction to the latest channel message, but it is not a reaction of the sender.
// Applying it would remove the owner's own reaction and, on another device of the owner,
// queue the message for the next summary, so the devices would resend summaries forever.
if part.is_reaction && mime_parser.broadcast_reactions.is_none() {
let reaction_str = simplify::remove_footers(part.msg.as_str());
let is_incoming_fresh =
mime_parser.incoming && !seen && chat_id_blocked == Blocked::Not;
@@ -2402,7 +2417,10 @@ async fn handle_edit_delete(
let Some(msg_id) = message::rfc724_mid_exists(context, rfc724_mid).await? else {
warn!(context, "Delete message: {rfc724_mid:?} not found.");
// Insert a tombstone so that the message will be ignored if it arrives later within a period specified in prune_tombstones().
insert_tombstone(context, rfc724_mid, false).await?;
context
.sql
.transaction(|tx| insert_tombstone(tx, rfc724_mid, false))
.await?;
continue;
};
+4 -7
View File
@@ -3853,7 +3853,7 @@ async fn test_unsigned_chat_group_hdr() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_addr = bob.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_addr = bob.get_primary_self_addr().await?;
let bob_id = alice.add_or_lookup_contact_id(bob).await;
let alice_chat_id = create_group(alice, "foos").await?;
add_contact_to_chat(alice, alice_chat_id, bob_id).await?;
@@ -4541,7 +4541,7 @@ async fn test_outgoing_msg_forgery() -> Result<()> {
let mut tcm = TestContextManager::new();
let export_dir = tempfile::tempdir().unwrap();
let alice = &tcm.alice().await;
let alice_addr = &alice.get_config(Config::ConfiguredAddr).await?.unwrap();
let alice_addr = &alice.get_primary_self_addr().await?;
imex(alice, ImexMode::ExportSelfKeys, export_dir.path(), None).await?;
// We need Bob only to encrypt the forged message to Alice's key, actually Bob doesn't
// participate in the scenario.
@@ -5638,7 +5638,7 @@ async fn test_bcc_not_a_group() -> Result<()> {
async fn test_lookup_key_contact_by_address_self() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let addr = &t.get_config(Config::ConfiguredAddr).await?.unwrap();
let addr = &t.get_primary_self_addr().await?;
assert_eq!(
lookup_key_contact_by_address(t, addr, None).await?,
Some(ContactId::SELF)
@@ -5690,10 +5690,7 @@ async fn test_outgoing_determined_by_signature() -> Result<()> {
let alice_dev2 = &tcm.unconfigured().await;
alice_dev2.configure_addr(different_from).await;
key::store_self_keypair(alice_dev2, &alice_keypair()).await?;
assert_ne!(
alice.get_config(Config::ConfiguredAddr).await?.unwrap(),
different_from
);
assert_ne!(alice.get_primary_self_addr().await?, different_from);
// Send message from alice_dev2 and check alice sees it as outgoing
let chat_id = alice_dev2.create_chat_id(bob).await;
+35 -47
View File
@@ -4,9 +4,8 @@ use std::num::NonZeroUsize;
use std::pin::Pin;
use std::sync::atomic::Ordering;
use anyhow::{Context as _, Error, Result, bail};
use anyhow::{Context as _, Error, Result};
use async_channel::{self as channel, Receiver, Sender};
use futures::future::try_join_all;
use futures_lite::FutureExt;
use tokio::sync::{RwLock, oneshot};
use tokio::task::{self, JoinSet};
@@ -319,7 +318,7 @@ impl SchedulerState {
}
while let Some(fetched) = set.join_next().await {
if let Ok(true) = fetched {
if fetched.context("background_fetch_any: Failed to join a task")? {
break;
}
}
@@ -403,11 +402,7 @@ pub(crate) struct Scheduler {
recently_seen_loop: RecentlySeenLoop,
}
async fn inbox_loop(
ctx: Context,
started: oneshot::Sender<()>,
inbox_handlers: ImapConnectionHandlers,
) {
async fn inbox_loop(ctx: Context, inbox_handlers: ImapConnectionHandlers) {
use futures::future::FutureExt;
info!(ctx, "Starting inbox loop.");
@@ -420,10 +415,6 @@ async fn inbox_loop(
let ctx1 = ctx.clone();
let fut = async move {
let ctx = ctx1;
if let Err(()) = started.send(()) {
warn!(ctx, "Inbox loop, missing started receiver.");
return;
};
let mut old_session: Option<Session> = None;
loop {
@@ -611,11 +602,7 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session)
Ok(session)
}
async fn smtp_loop(
ctx: Context,
started: oneshot::Sender<()>,
smtp_handlers: SmtpConnectionHandlers,
) {
async fn smtp_loop(ctx: Context, smtp_handlers: SmtpConnectionHandlers) {
use futures::future::FutureExt;
info!(ctx, "Starting SMTP loop.");
@@ -628,10 +615,6 @@ async fn smtp_loop(
let ctx1 = ctx.clone();
let fut = async move {
let ctx = ctx1;
if let Err(()) = started.send(()) {
warn!(&ctx, "SMTP loop, missing started receiver.");
return;
}
// Reschedule the check to catch changes lost to a restart.
schedule_keyupdate_check(&ctx).await.log_err(&ctx).ok();
@@ -678,11 +661,11 @@ async fn smtp_loop(
"SMTP has messages to retry, planning to retry {t} seconds later."
);
let duration = std::time::Duration::from_secs(t);
tokio::time::timeout(duration, async {
idle_interrupt_receiver.recv().await.unwrap_or_default()
})
.await
.unwrap_or_default();
if let Ok(Err(async_channel::RecvError)) =
tokio::time::timeout(duration, idle_interrupt_receiver.recv()).await
{
break;
}
let slept = time_elapsed(&now).as_secs();
timeout = Some(cmp::max(
t,
@@ -706,11 +689,15 @@ async fn smtp_loop(
info!(ctx, "SMTP has no messages to retry, waiting for interrupt.");
let interrupt = idle_interrupt_receiver.recv();
if next_check != 0 {
tokio::time::timeout(std::time::Duration::from_secs(wait), interrupt)
.await
.ok();
if let Ok(Err(async_channel::RecvError)) =
tokio::time::timeout(std::time::Duration::from_secs(wait), interrupt).await
{
break;
}
} else {
interrupt.await.ok();
if let Err(async_channel::RecvError) = interrupt.await {
break;
}
}
};
@@ -732,20 +719,17 @@ impl Scheduler {
pub async fn start(ctx: &Context) -> Result<Self> {
let (smtp, smtp_handlers) = SmtpConnectionState::new();
let (smtp_start_send, smtp_start_recv) = oneshot::channel();
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 start_recvs = Vec::new();
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))
task::spawn(inbox_loop(ctx, inbox_handlers))
};
let addr = configured_login_param.addr.clone();
let folder = configured_login_param
@@ -758,14 +742,12 @@ impl Scheduler {
handle,
};
inboxes.push(inbox);
start_recvs.push(inbox_start_recv);
}
let smtp_handle = {
let ctx = ctx.clone();
task::spawn(smtp_loop(ctx, smtp_start_send, smtp_handlers))
task::spawn(smtp_loop(ctx, smtp_handlers))
};
start_recvs.push(smtp_start_recv);
let ephemeral_handle = {
let ctx = ctx.clone();
@@ -794,11 +776,6 @@ impl Scheduler {
recently_seen_loop,
};
// wait for all loops to be started
if let Err(err) = try_join_all(start_recvs).await {
bail!("failed to start scheduler: {err}");
}
info!(ctx, "scheduler is running");
Ok(res)
}
@@ -851,20 +828,31 @@ impl Scheduler {
let timeout_duration = std::time::Duration::from_secs(30);
let tracker = TaskTracker::new();
for b in self.inboxes {
for mut b in self.inboxes {
let context = context.clone();
tracker.spawn(async move {
tokio::time::timeout(timeout_duration, b.handle)
if tokio::time::timeout(timeout_duration, &mut b.handle)
.await
.log_err(&context)
.is_err()
{
warn!(context, "IMAP shutdown timed out; aborting task.");
b.handle.abort();
b.handle.await.ok();
}
});
}
{
let context = context.clone();
tracker.spawn(async move {
tokio::time::timeout(timeout_duration, self.smtp_handle)
let mut handle = self.smtp_handle;
if tokio::time::timeout(timeout_duration, &mut handle)
.await
.log_err(&context)
.is_err()
{
warn!(context, "SMTP shutdown timed out; aborting task.");
handle.abort();
handle.await.ok();
}
});
}
tracker.close();
+5 -2
View File
@@ -33,7 +33,7 @@ pub(crate) use qrinvite::QrInvite;
use crate::token::Namespace;
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_').remove(b'@');
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_');
fn inviter_progress(
context: &Context,
@@ -543,7 +543,10 @@ pub(crate) async fn handle_securejoin_handshake(
)
.await?;
insert_into_smtp(context, &rfc724_mid, &queued_message).await?;
context
.sql
.transaction(|tx| insert_into_smtp(tx, &rfc724_mid, &queued_message))
.await?;
context.scheduler.interrupt_smtp().await;
Ok(HandshakeMessage::Done)
+4 -1
View File
@@ -339,7 +339,10 @@ pub(crate) async fn send_handshake_message(
recipients.to_vec(),
)
.await?;
insert_into_smtp(context, &rfc724_mid, &queued_msg).await?;
context
.sql
.transaction(|tx| insert_into_smtp(tx, &rfc724_mid, &queued_msg))
.await?;
context.scheduler.interrupt_smtp().await;
} else {
+6 -10
View File
@@ -50,11 +50,7 @@ async fn test_setup_contact_ext(case: SetupContactCase) -> (TestContext, TestCon
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let alice_addr = &alice
.get_config(Config::ConfiguredAddr)
.await
.unwrap()
.unwrap();
let alice_addr = alice.get_primary_self_addr().await.unwrap();
if case == SetupContactCase::AliceHasName {
alice
.set_config(Config::Displayname, Some("Alice"))
@@ -118,7 +114,7 @@ async fn test_setup_contact_ext(case: SetupContactCase) -> (TestContext, TestCon
let contact_alice_id = bob.add_or_lookup_contact_no_key(&alice).await.id;
let sent = bob.pop_sent_msg().await;
assert!(!sent.payload.contains("Bob Examplenet"));
assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap());
assert_eq!(sent.recipients, *alice_addr);
let msg = alice.parse_msg(&sent).await;
assert!(msg.signature.is_none());
assert_eq!(
@@ -1160,12 +1156,12 @@ async fn test_get_securejoin_qr_encoding() -> Result<()> {
let qr = get_securejoin_qr(alice, None).await?;
assert!(
qr.contains("a=jk%25l@example.net"),
"{qr} doesn't contain 'a=jk%25l@example.net'"
qr.contains("a=jk%25l%40example.net"),
"{qr} doesn't contain 'a=jk%25l%40example.net'"
);
assert!(
qr.contains("r=asdf@example.org,alice@example.org"),
"{qr} doesn't contain 'r=asdf@example.org,alice@example.org'"
qr.contains("r=asdf%40example.org,alice%40example.org"),
"{qr} doesn't contain 'r=asdf%40example.org,alice%40example.org'"
);
let qr = check_qr(bob, &qr).await?;
+133 -97
View File
@@ -13,7 +13,6 @@ use rusqlite::OptionalExtension as _;
use tokio::task;
use crate::chat::{ChatId, add_info_msg_with_cmd};
use crate::config::Config;
use crate::contact::{Contact, ContactId};
use crate::context::Context;
use crate::events::EventType;
@@ -32,21 +31,26 @@ use crate::transport::{
ConfiguredLoginParam, ConfiguredServerLoginParam, prioritize_server_login_params,
};
/// SMTP connection with information about it.
#[derive(Debug)]
struct Connection {
transport: SmtpTransport<Box<dyn SessionBufStream>>,
/// Email address that should be put into the From field when sending over this connection.
from: EmailAddress,
/// ID of the transport from which SMTP connection configuration comes from.
transport_id: u32,
/// Timestamp of last successful send/receive network interaction
/// (eg connect or send succeeded).
last_success: tools::Time,
}
#[derive(Default)]
pub(crate) struct Smtp {
/// SMTP connection.
transport: Option<SmtpTransport<Box<dyn SessionBufStream>>>,
/// Email address we are sending from.
from: Option<EmailAddress>,
/// Transport we are connected to.
transport_id: Option<u32>,
/// Timestamp of last successful send/receive network interaction
/// (eg connect or send succeeded). On initialization and disconnect
/// it is set to None.
last_success: Option<tools::Time>,
connection: Option<Connection>,
pub(crate) connectivity: ConnectivityStore,
@@ -61,7 +65,10 @@ async fn sorted_transports(context: &Context) -> Result<Vec<(u32, ConfiguredLogi
.query_map_vec(
"SELECT transports.id, configured_param FROM transports
LEFT JOIN smtp_success ON smtp_success.transport_id=transports.id
ORDER BY IFNULL(smtp_success.id, 0) DESC, transports.id ASC",
LEFT JOIN smtp_failure ON smtp_failure.transport_id=transports.id
ORDER BY IFNULL(smtp_success.id, 0) DESC,
IFNULL(smtp_failure.id, 0) ASC,
transports.id ASC",
(),
|row| {
let id: u32 = row.get(0)?;
@@ -79,10 +86,37 @@ async fn record_success(context: &Context, transport_id: u32) -> Result<()> {
// if the row exists already, so it becomes the highest rowid in the table.
context
.sql
.execute(
"INSERT OR REPLACE INTO smtp_success (transport_id) VALUES (?)",
(transport_id,),
)
.transaction(|t| {
t.execute(
"INSERT OR REPLACE INTO smtp_success (transport_id) VALUES (?)",
(transport_id,),
)?;
t.execute(
"DELETE FROM smtp_failure WHERE transport_id = ?",
(transport_id,),
)?;
Ok(())
})
.await?;
Ok(())
}
/// Records a failure and removes last remembered success for
/// SMTP transport so another transport is tried next time.
async fn record_failure(context: &Context, transport_id: u32) -> Result<()> {
context
.sql
.transaction(|t| {
t.execute(
"INSERT OR REPLACE INTO smtp_failure (transport_id) VALUES (?)",
(transport_id,),
)?;
t.execute(
"DELETE FROM smtp_success WHERE transport_id = ?",
(transport_id,),
)?;
Ok(())
})
.await?;
Ok(())
}
@@ -95,30 +129,25 @@ impl Smtp {
/// Disconnect the SMTP transport and drop it entirely.
pub fn disconnect(&mut self) {
if let Some(mut transport) = self.transport.take() {
if let Some(mut connection) = self.connection.take() {
// Closing connection with a QUIT command may take some time, especially if it's a
// stale connection and an attempt to send the command times out. Send a command in a
// separate task to avoid waiting for reply or timeout.
task::spawn(async move { transport.quit().await });
task::spawn(async move { connection.transport.quit().await });
}
self.transport_id = None;
self.from = None;
self.last_success = None;
}
/// Return true if smtp was connected but is not known to
/// have been successfully used the last 60 seconds
pub fn has_maybe_stale_connection(&self) -> bool {
if let Some(last_success) = self.last_success {
time_elapsed(&last_success).as_secs() > 60
} else {
false
}
self.connection
.as_ref()
.is_some_and(|connection| time_elapsed(&connection.last_success).as_secs() > 60)
}
/// Check whether we are connected.
pub fn is_connected(&self) -> bool {
self.transport.is_some()
self.connection.is_some()
}
/// Connect using configured parameters.
@@ -143,14 +172,12 @@ impl Smtp {
&lp.smtp_password,
&proxy_config,
&lp.addr,
transport_id,
lp.strict_tls(proxy_config.is_some())?,
)
.await
{
Ok(()) => {
self.transport_id = Some(transport_id);
return Ok(());
}
Ok(()) => return Ok(()),
Err(err) => {
warn!(
context,
@@ -163,6 +190,7 @@ impl Smtp {
}
/// Connect using the provided login params.
#[expect(clippy::too_many_arguments)]
pub async fn connect(
&mut self,
context: &Context,
@@ -170,6 +198,7 @@ impl Smtp {
password: &str,
proxy_config: &Option<ProxyConfig>,
addr: &str,
transport_id: u32,
strict_tls: bool,
) -> Result<()> {
if self.is_connected() {
@@ -179,7 +208,6 @@ impl Smtp {
let from = EmailAddress::new(addr.to_string())
.with_context(|| format!("Invalid address {addr:?}"))?;
self.from = Some(from);
let login_params =
prioritize_server_login_params(&context.sql, login_params, "smtp").await?;
@@ -204,8 +232,12 @@ impl Smtp {
}
};
self.transport = Some(transport);
self.last_success = Some(tools::Time::now());
self.connection = Some(Connection {
transport,
from,
transport_id,
last_success: tools::Time::now(),
});
context.emit_event(EventType::SmtpConnected(format!(
"SMTP-LOGIN as {} ok",
@@ -230,7 +262,7 @@ pub(crate) enum SendResult {
}
/// Tries to send a message.
pub(crate) async fn smtp_send(
async fn smtp_send(
context: &Context,
recipients: &[async_smtp::EmailAddress],
message: &str,
@@ -246,14 +278,37 @@ pub(crate) async fn smtp_send(
smtp.connectivity.set_working(context);
let send_result = smtp.send(context, recipients, message.as_bytes()).await;
smtp.last_send_error = send_result.as_ref().err().map(|e| e.to_string());
let Some(ref mut connection) = smtp.connection else {
warn!(
context,
"Failed to send a message because SMTP client is not connected."
);
return SendResult::Retry;
};
let transport_id = connection.transport_id;
let send_result = connection
.send(context, recipients, message.as_bytes())
.await;
smtp.last_send_error = send_result.as_ref().err().map(|e| format!("{e:#}"));
let status = match send_result {
Err(crate::smtp::send::Error::SmtpSend(err)) => {
Err(send::Error::SmtpSend(err)) => {
// Remote error, retry later.
info!(context, "SMTP failed to send: {:?}.", &err);
// Record failure on any error returned by the server, but not network errors.
if matches!(
err,
async_smtp::error::Error::Permanent(_) | async_smtp::error::Error::Transient(_)
) && let Err(err) = record_failure(context, transport_id).await
{
warn!(
context,
"Failed to remove success for {transport_id}: {err:#}."
)
}
let res = match err {
async_smtp::error::Error::Permanent(ref response) => {
// Workaround for incorrectly configured servers returning permanent errors
@@ -327,44 +382,28 @@ pub(crate) async fn smtp_send(
}
};
// this clears last_success info
info!(context, "Failed to send message over SMTP, disconnecting.");
smtp.disconnect();
res
}
Err(crate::smtp::send::Error::Envelope(err)) => {
// Local error, job is invalid, do not retry.
smtp.disconnect();
warn!(context, "SMTP job is invalid: {err:#}.");
SendResult::Failure(err)
}
Err(crate::smtp::send::Error::NoTransport) => {
// Should never happen.
// It does not even make sense to disconnect here.
error!(context, "SMTP job failed because SMTP has no transport.");
SendResult::Failure(format_err!("SMTP has not transport"))
}
Err(crate::smtp::send::Error::Other(err)) => {
Err(send::Error::Other(err)) => {
// Local error, job is invalid, do not retry.
smtp.disconnect();
warn!(context, "Unable to load SMTP job: {err:#}.");
SendResult::Failure(err)
}
Ok(()) => SendResult::Success,
};
if matches!(status, SendResult::Success) {
debug_assert!(smtp.transport_id.is_some());
if let Some(transport_id) = smtp.transport_id
&& let Err(err) = record_success(context, transport_id).await
{
warn!(
context,
"Failed to record successful use of transport {transport_id} in smtp_success table: {err:#}."
);
Ok(()) => {
record_success(context, transport_id)
.await
.with_context(|| {
format!("Failed to record successful use of transport {transport_id}")
})
.log_err(context)
.ok();
SendResult::Success
}
}
};
if let SendResult::Failure(err) = &status
&& let Some(msg_id) = msg_id
@@ -390,28 +429,21 @@ pub(crate) async fn smtp_send(
/// Inserts a tombstone for `rfc724_mid`
/// and queues the message for SMTP sending.
pub(crate) async fn insert_into_smtp(
context: &Context,
pub(crate) fn insert_into_smtp(
tx: &mut rusqlite::Transaction<'_>,
rfc724_mid: &str,
queued_msg: &QueuedMail,
) -> Result<()> {
let now = tools::time();
let msg_id = message::insert_tombstone(context, rfc724_mid, false).await?;
context
.sql
.transaction(|transaction| queue::enqueue_mail(transaction, now, msg_id, queued_msg, None))
.await?;
let msg_id = message::insert_tombstone(tx, rfc724_mid, false)?;
queue::enqueue_mail(tx, now, msg_id, queued_msg, None)?;
Ok(())
}
/// Sends message identified by `smtp` table rowid over SMTP connection.
///
/// Removes row if the message should not be retried, otherwise increments retry count.
pub(crate) async fn send_msg_to_smtp(
context: &Context,
smtp: &mut Smtp,
rowid: i64,
) -> anyhow::Result<()> {
pub(crate) async fn send_msg_to_smtp(context: &Context, smtp: &mut Smtp, rowid: i64) -> Result<()> {
if let Err(err) = smtp
.connect_configured(context)
.await
@@ -472,9 +504,10 @@ pub(crate) async fn send_msg_to_smtp(
let mut recipients = queued_mail.recipients.clone();
if queued_mail.bcc_self {
let from_addr = smtp
.from
.connection
.as_ref()
.context("No From address available, likely not connected")?
.context("Not connected")?
.from
.to_string();
add_self_recipients(
context,
@@ -504,19 +537,24 @@ pub(crate) async fn send_msg_to_smtp(
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let from_addr = smtp
.from
.connection
.as_ref()
.context("No From address available, likely not connected")?
.context("Not connected")?
.from
.to_string();
let transport_id = smtp.transport_id.context("Not connected")?;
let transport_id = smtp
.connection
.as_ref()
.context("Not connected")?
.transport_id;
let chunk_size = context
.get_max_smtp_rcpt_to(transport_id, &from_addr)
.await?
.max(1);
let rendered_mail =
mimefactory::render_queued_mail(queued_mail, &public_key, &secret_key, from_addr)?;
mimefactory::render_queued_mail(queued_mail, &public_key, &secret_key, from_addr.clone())?;
let body = rendered_mail.message;
info!(
@@ -587,15 +625,9 @@ pub(crate) async fn send_msg_to_smtp(
.await?;
if let Some((chat_id, timestamp_sort)) = res {
let addr = context.get_config(Config::ConfiguredAddr).await?;
let text = unencrypted_email(
context,
addr.unwrap_or_default()
.split('@')
.nth(1)
.unwrap_or_default(),
)
.await;
let text =
unencrypted_email(context, from_addr.split('@').nth(1).unwrap_or_default())
.await;
add_info_msg_with_cmd(
context,
chat_id,
@@ -760,9 +792,10 @@ async fn send_mdn_rfc724_mid(
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let from = smtp
.from
.connection
.as_ref()
.context("No From address, not connected")?
.context("Not connected")?
.from
.to_string();
let rendered_msg =
mimefactory::render_queued_mail(queued_mdn, &public_key, &secret_key, from.clone())?;
@@ -780,7 +813,10 @@ async fn send_mdn_rfc724_mid(
.ok()
})
.collect();
message::insert_tombstone(context, &rendered_msg.rfc724_mid, false).await?;
context
.sql
.transaction(|tx| message::insert_tombstone(tx, &rendered_msg.rfc724_mid, false))
.await?;
match smtp_send(context, &recipients, &body, smtp, None).await {
SendResult::Success => {
if !recipients.is_empty() {
+9 -21
View File
@@ -1,31 +1,27 @@
//! # SMTP message sending
use anyhow::Context as _;
use async_smtp::{EmailAddress, Envelope, SendableEmail};
use super::Smtp;
use super::Connection;
use crate::config::Config;
use crate::context::Context;
use crate::log::warn;
use crate::tools;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Envelope error: {}", _0)]
Envelope(anyhow::Error),
#[error("Send error: {}", _0)]
SmtpSend(async_smtp::error::Error),
#[error("SMTP has no transport")]
NoTransport,
#[error("{}", _0)]
#[error("{:#}", _0)]
Other(#[from] anyhow::Error),
}
impl Smtp {
impl Connection {
/// Send a prepared mail to recipients.
/// On successful send out Ok() is returned.
pub async fn send(
pub(super) async fn send(
&mut self,
context: &Context,
recipients: &[EmailAddress],
@@ -38,21 +34,13 @@ impl Smtp {
context.ratelimit.write().await.send();
}
let envelope =
Envelope::new(self.from.clone(), recipients.to_vec()).map_err(Error::Envelope)?;
let envelope = Envelope::new(Some(self.from.clone()), recipients.to_vec())
.context("Envelope error")?;
let mail = SendableEmail::new(envelope, message);
let Some(ref mut transport) = self.transport else {
warn!(
context,
"Failed to send a message because SMTP client has no SmtpTransport."
);
return Err(Error::NoTransport);
};
self.transport.send(mail).await.map_err(Error::SmtpSend)?;
transport.send(mail).await.map_err(Error::SmtpSend)?;
self.last_success = Some(tools::Time::now());
self.last_success = tools::Time::now();
Ok(())
}
}
+50 -29
View File
@@ -1,8 +1,25 @@
use anyhow::Result;
use crate::test_utils::TestContextManager;
use crate::test_utils::{TestContext, TestContextManager};
use crate::transport;
async fn sorted_transport_addrs(t: &TestContext) -> Result<String> {
let transports = super::sorted_transports(t).await?;
let addrs: Vec<String> = transports
.into_iter()
.map(|(_transport_id, transport)| transport.addr)
.collect();
Ok(addrs.join(" "))
}
async fn sorted_transport_ids(t: &TestContext) -> Result<Vec<u32>> {
let transports = super::sorted_transports(t).await?;
Ok(transports
.into_iter()
.map(|(transport_id, _transport)| transport_id)
.collect())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_smtp_candidates() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -12,38 +29,42 @@ async fn test_smtp_candidates() -> Result<()> {
transport::add_pseudo_transport(t, "bar@example.net").await?;
transport::add_pseudo_transport(t, "baz@example.net").await?;
let transports = super::sorted_transports(t).await?;
let [
(transport_id1, ref transport1),
(transport_id2, ref transport2),
(transport_id3, ref transport3),
] = transports[..]
else {
panic!("Unexpected number of transports");
};
// By default first added transport is used first.
assert_eq!(transport1.addr, "foo@example.net");
assert_eq!(transport2.addr, "bar@example.net");
assert_eq!(transport3.addr, "baz@example.net");
assert_eq!(
sorted_transport_addrs(t).await?,
"foo@example.net bar@example.net baz@example.net"
);
assert_eq!(sorted_transport_ids(t).await?, [1, 2, 3]);
super::record_success(t, transport_id3).await?;
let transports2 = super::sorted_transports(t).await?;
assert_eq!(transports2[0].0, transport_id3);
assert_eq!(transports2[1].0, transport_id1);
assert_eq!(transports2[2].0, transport_id2);
super::record_success(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [3, 1, 2]);
super::record_success(t, transport_id2).await?;
let transports3 = super::sorted_transports(t).await?;
assert_eq!(transports3[0].0, transport_id2);
assert_eq!(transports3[1].0, transport_id3);
assert_eq!(transports3[2].0, transport_id1);
super::record_success(t, 2).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 3, 1]);
super::record_success(t, transport_id3).await?;
let transports4 = super::sorted_transports(t).await?;
assert_eq!(transports4[0].0, transport_id3);
assert_eq!(transports4[1].0, transport_id2);
assert_eq!(transports4[2].0, transport_id1);
super::record_success(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [3, 2, 1]);
super::record_failure(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 3]);
// New transport is added before all failing transports.
transport::add_pseudo_transport(t, "qux@example.net").await?;
assert_eq!(
sorted_transport_addrs(t).await?,
"bar@example.net foo@example.net qux@example.net baz@example.net"
);
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 4, 3]);
super::record_success(t, 4).await?;
assert_eq!(sorted_transport_ids(t).await?, [4, 2, 1, 3]);
super::record_failure(t, 4).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 3, 4]);
super::record_failure(t, 3).await?;
assert_eq!(sorted_transport_ids(t).await?, [2, 1, 4, 3]);
super::record_failure(t, 2).await?;
assert_eq!(sorted_transport_ids(t).await?, [1, 4, 3, 2]);
Ok(())
}
+26
View File
@@ -2687,6 +2687,32 @@ CREATE TABLE smtp_success (
.await?;
}
inc_and_check(&mut migration_version, 169)?;
if dbversion < migration_version {
sql.execute_migration(
"
CREATE TABLE smtp_failure (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
transport_id INTEGER UNIQUE NOT NULL,
FOREIGN KEY(transport_id) REFERENCES transports(id) ON DELETE CASCADE
) STRICT;
",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 170)?;
if dbversion < migration_version {
// Lets `delete_messages()` find rows marked for deletion without reading the folder's rows.
sql.execute_migration(
"CREATE INDEX IF NOT EXISTS imap_pending_deletions
ON imap(transport_id, folder) WHERE target != folder",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?
+17 -1
View File
@@ -431,6 +431,12 @@ https://delta.chat/donate"))]
#[strum(props(fallback = "Message pinned by %1$s."))]
MsgMessagePinnedBy = 244,
#[strum(props(fallback = "You unpinned a message."))]
MsgYouUnpinnedAMessage = 246,
#[strum(props(fallback = "Message unpinned by %1$s."))]
MsgMessageUnpinnedBy = 247,
}
impl StockMessage {
@@ -617,7 +623,6 @@ pub(crate) async fn msg_chat_description_changed(
}
/// Stock strings for pinning a message; used in info messages, once tapped, UI scrolled to the pinned message.
/// For unpinning a message, we do not add a visible info message as this is of fewer interest.
pub(crate) async fn msg_pinned(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouPinnedAMessage)
@@ -627,6 +632,17 @@ pub(crate) async fn msg_pinned(context: &Context, by_contact: ContactId) -> Stri
}
}
/// Stock strings for unpinning a message; used in info messages, once tapped, UI scrolled to the unpinned message.
/// Unpinning messages are not notified, but still shown in the chat as otherwise the last message may read "USER pinned a message", but nothing is pinned.
pub(crate) async fn msg_unpinned(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouUnpinnedAMessage)
} else {
translated(context, StockMessage::MsgMessageUnpinnedBy)
.replace1(&by_contact.get_stock_name(context).await)
}
}
/// Stock string, one of:
/// - `Member %1$s added.`,
/// - `You added member %1$s.`,
+11 -9
View File
@@ -7,7 +7,7 @@ use std::str;
use anyhow::Result;
use num_traits::FromPrimitive;
use crate::calls::{CallState, call_state};
use crate::calls::CallState;
use crate::chat::Chat;
use crate::constants::Chattype;
use crate::constants::DC_DESIRED_TEXT_LEN;
@@ -68,9 +68,11 @@ impl Summary {
chat: &Chat,
contact: Option<&Contact>,
) -> Result<Summary> {
if let Some((reaction_msg, reaction_contact_id, reaction)) = chat
.get_last_reaction_if_newer_than(context, msg.timestamp_sort)
.await?
let is_broadcast = matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast);
if !is_broadcast
&& let Some((reaction_msg, reaction_contact_id, reaction)) = chat
.get_last_reaction_if_newer_than(context, msg.timestamp_sort)
.await?
{
// there is a reaction newer than the latest message, show that.
// sorting and therefore date is still the one of the last message,
@@ -237,11 +239,11 @@ impl Message {
append_text = true;
}
Viewtype::Call => {
let call_info = context.load_call_by_id(self.id).await.unwrap_or(None);
let has_video = call_info.is_some_and(|c| c.has_video_initially());
let call_state = call_state(context, self.id)
.await
.unwrap_or(CallState::Alerting);
let call_info = context.load_call_by_message(self.clone());
let has_video = call_info.as_ref().is_some_and(|c| c.has_video_initially());
let call_state = call_info
.as_ref()
.map_or(CallState::Alerting, |c| c.call_state());
emoji = Some(if has_video { "🎥" } else { "📞" });
type_name = Some(match call_state {
CallState::Alerting | CallState::Active | CallState::Completed { .. } => {
+50 -31
View File
@@ -193,21 +193,11 @@ impl TestContextManager {
to.recv_msg(&sent).await
}
pub async fn change_addr(&self, test_context: &TestContext, new_addr: &str) {
self.section(&format!(
"{} changes her self address and reconfigures",
test_context.name()
));
pub async fn change_addr(&self, test_context: &mut TestContext, new_addr: &str) {
test_context.add_transport(new_addr).await;
test_context.set_primary_self_addr(new_addr).await.unwrap();
test_context.set_sending_addr(new_addr).await;
// ensure_secret_key_exists() is called during configure
key::ensure_secret_key_exists(test_context).await.unwrap();
assert_eq!(
test_context.get_primary_self_addr().await.unwrap(),
new_addr
);
}
/// Executes SecureJoin protocol between `scanner` and `scanned`.
@@ -252,10 +242,9 @@ impl TestContextManager {
qr: &str,
) -> ChatId {
assert!(joiner.pop_sent_msg_opt().await.is_none());
let inviter_addr = inviters[0].get_primary_self_addr().await.unwrap();
let inviter_addr = inviters[0].sending_addr().await;
for inviter in inviters {
assert!(inviter.pop_sent_msg_opt().await.is_none());
assert_eq!(inviter.get_primary_self_addr().await.unwrap(), inviter_addr);
}
let chat_id = join_securejoin(&joiner.ctx, qr).await.unwrap();
@@ -435,6 +424,9 @@ pub struct TestContext {
pub evtracker: EventTracker,
log_sink: LogSink,
/// From address to be used when emulating sending.
from_for_test_msgs: Option<String>,
}
impl TestContext {
@@ -530,6 +522,7 @@ impl TestContext {
dir,
evtracker: EventTracker::new(evtracker_receiver),
log_sink,
from_for_test_msgs: None,
}
}
@@ -560,10 +553,6 @@ impl TestContext {
add_pseudo_transport(&self.ctx, addr)
.await
.expect("Failed to add pseudo transport");
self.ctx
.set_config(Config::ConfiguredAddr, Some(addr))
.await
.expect("Failed to configure address");
if let Some(name) = addr.split('@').next() {
self.set_name(name);
@@ -586,6 +575,28 @@ impl TestContext {
self.self_public_key.lock().await.take();
}
pub async fn set_sending_addr(&mut self, addr: &str) {
if !self
.sql
.exists("SELECT COUNT(*) FROM transports WHERE addr=?", (addr,))
.await
.unwrap()
{
panic!("Address {addr} is not configured");
}
self.from_for_test_msgs = Some(addr.to_string());
}
pub async fn sending_addr(&self) -> String {
if let Some(ref from) = self.from_for_test_msgs {
from.clone()
} else {
self.get_primary_self_addr()
.await
.expect("Cannot get From address")
}
}
/// Retrieves a sent message from the jobs table.
///
/// This retrieves and removes a message which has been scheduled to send from the jobs
@@ -632,10 +643,7 @@ ORDER BY id"
.await
.expect("Failed to load queued mail");
if queued_mail.bcc_self {
let from = self
.get_primary_self_addr()
.await
.expect("Cannot get From address");
let from = self.sending_addr().await;
smtp::add_self_recipients(
&self.ctx,
&mut queued_mail.recipients,
@@ -734,10 +742,7 @@ ORDER BY id"
.await
.expect("Failed to load queued mail");
if queued_mail.bcc_self {
let from = self
.get_primary_self_addr()
.await
.expect("Cannot get self address");
let from = self.sending_addr().await;
smtp::add_self_recipients(
&self.ctx,
&mut queued_mail.recipients,
@@ -895,9 +900,23 @@ ORDER BY id"
.expect("failed to load msg")
}
/// Returns the `EXPLAIN QUERY PLAN` steps of `query`.
pub async fn query_plan(&self, query: &str) -> Result<Vec<String>> {
self.sql
.call(true, |conn| {
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {query}"))?;
let plan = stmt
.raw_query()
.mapped(|row| row.get::<_, String>(3))
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(plan)
})
.await
}
/// Returns the [`ContactId`] for the other [`TestContext`], creating a contact if necessary.
pub async fn add_or_lookup_address_contact_id(&self, other: &TestContext) -> ContactId {
let primary_self_addr = other.ctx.get_primary_self_addr().await.unwrap();
let primary_self_addr = other.sending_addr().await;
let addr = ContactAddress::new(&primary_self_addr).unwrap();
// MailinglistAddress is the lowest allowed origin, we'd prefer to not modify the
// origin when creating this contact.
@@ -952,7 +971,7 @@ ORDER BY id"
/// If the contact does not exist yet, a new contact will be created
/// with the correct fingerprint, but without the public key.
pub async fn add_or_lookup_contact_id_no_key(&self, other: &TestContext) -> ContactId {
let primary_self_addr = other.ctx.get_primary_self_addr().await.unwrap();
let primary_self_addr = other.sending_addr().await;
let addr = ContactAddress::new(&primary_self_addr).unwrap();
let fingerprint = self_fingerprint(other).await.unwrap();
@@ -1242,13 +1261,14 @@ ORDER BY id"
}
pub async fn encrypt_raw_message(
context: &Context,
context: &TestContext,
receivers: &[&TestContext],
payload: &[u8],
) -> Result<String> {
let public_key = key::load_self_public_key(context).await?;
let from = context.sending_addr().await;
let aheader = Aheader {
addr: context.get_primary_self_addr().await?,
addr: from.clone(),
public_key: public_key.clone(),
prefer_encrypt: EncryptPreference::Mutual,
};
@@ -1259,7 +1279,6 @@ pub async fn encrypt_raw_message(
encryption_keyring.push(key::load_self_public_key(receiver).await?);
}
let from = context.get_primary_self_addr().await?;
let compress = false;
let mut cleartext = format!("Autocrypt: {aheader}").into_bytes();
+6 -8
View File
@@ -20,13 +20,14 @@ use crate::test_utils::TestContextManager;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_change_primary_self_addr() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let mut alice = tcm.alice().await;
let bob = tcm.bob().await;
tcm.send_recv_accept(&alice, &bob, "Hi").await;
let bob_alice_chat = bob.create_chat(&alice).await;
tcm.change_addr(&alice, "alice@someotherdomain.xyz").await;
tcm.change_addr(&mut alice, "alice@someotherdomain.xyz")
.await;
tcm.section("Bob sends a message to Alice, encrypting to her previous key");
let sent = bob.send_text(bob_alice_chat.id, "hi back").await;
@@ -63,7 +64,7 @@ async fn check_aeap_transition(chat_for_transition: ChatForTransition) {
const ALICE_NEW_ADDR: &str = "alice2@example.net";
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice = &mut tcm.alice().await;
let bob = &tcm.bob().await;
tcm.send_recv_accept(alice, bob, "Hi").await;
@@ -105,10 +106,7 @@ async fn check_aeap_transition(chat_for_transition: ChatForTransition) {
check_that_transition_worked(bob, &groups, alice_contact, ALICE_NEW_ADDR).await;
tcm.section("Test switching back");
alice
.set_primary_self_addr("alice@example.org")
.await
.unwrap();
alice.set_sending_addr("alice@example.org").await;
let sent = alice
.send_text(chat_to_send, "Hello from my old addr!")
.await;
@@ -205,7 +203,7 @@ async fn test_aeap_replay_attack() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_write_to_alice_after_aeap() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice = &mut tcm.alice().await;
let bob = &tcm.bob().await;
let alice_grp_id = chat::create_group(alice, "Group").await?;
+42 -59
View File
@@ -276,14 +276,15 @@ impl ConfiguredLoginParam {
.await
}
/// Loads legacy configured param. Only used for tests and the migration.
/// Loads legacy configured param. Only used for tests and migration 131.
pub(crate) async fn load_legacy(context: &Context) -> Result<Option<Self>> {
if !context.get_config_bool(Config::Configured).await? {
return Ok(None);
}
let addr = context
.get_config(Config::ConfiguredAddr)
.sql
.get_raw_config("configured_addr")
.await?
.unwrap_or_default()
.trim()
@@ -480,11 +481,11 @@ pub(crate) async fn save_transport(
);
let addr = addr_normalize(&configured.addr);
let configured_addr = context.get_config(Config::ConfiguredAddr).await?;
let mut modified = context
let modified = context
.sql
.execute(
"INSERT INTO transports (addr, entered_param, configured_param, add_timestamp)
.transaction(|transaction| {
let modified = transaction.execute(
"INSERT INTO transports (addr, entered_param, configured_param, add_timestamp)
VALUES (?, ?, ?, ?)
ON CONFLICT (addr)
DO UPDATE SET entered_param=excluded.entered_param,
@@ -493,24 +494,18 @@ pub(crate) async fn save_transport(
WHERE entered_param != excluded.entered_param
OR configured_param != excluded.configured_param
OR add_timestamp < excluded.add_timestamp",
(
&addr,
serde_json::to_string(entered_param)?,
serde_json::to_string(configured)?,
add_timestamp,
),
)
.await?
> 0;
(
&addr,
serde_json::to_string(entered_param)?,
serde_json::to_string(configured)?,
add_timestamp,
),
)? > 0;
update_configured_addr(transaction)?;
Ok(modified)
})
.await?;
if configured_addr.is_none() {
// If there is no transport yet, use the new transport for sending
context
.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(&addr))
.await?;
modified = true;
}
Ok(modified)
}
@@ -625,7 +620,7 @@ pub(crate) async fn sync_transports(
)
.collect();
let (deleted_ids, reelected) = context
let deleted_ids = context
.sql
.transaction(|transaction| {
let mut deleted_ids = Vec::new();
@@ -636,10 +631,10 @@ pub(crate) async fn sync_transports(
}
deleted_ids.extend(delete_transport_row(transaction, addr, *timestamp)?);
}
update_configured_addr(transaction)?;
modified |= !deleted_ids.is_empty();
let reelected = maybe_update_sending_transport(transaction)?;
Ok((deleted_ids, reelected))
Ok(deleted_ids)
})
.await?;
@@ -647,12 +642,6 @@ pub(crate) async fn sync_transports(
purge_transport_caches(context, *transport_id).await;
}
if let Some(new_addr) = reelected {
info!(context, "Re-elected sending transport {new_addr:?}.");
context.sql.uncache_raw_config("configured_addr").await;
modified = true;
}
if modified {
context.self_public_key.lock().await.take();
context
@@ -711,34 +700,10 @@ pub(crate) async fn purge_transport_caches(context: &Context, transport_id: u32)
context.metadata.write().await.remove(&transport_id);
}
/// Elects another transport for sending if the current one vanished.
/// Any remaining transport works and selection is anyway moving
/// to the authority of the SMTP loop, see <https://github.com/chatmail/core/pull/8619>
pub(crate) fn maybe_update_sending_transport(
transaction: &mut rusqlite::Transaction,
) -> Result<Option<String>> {
let configured_addr: String = transaction.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| row.get(0),
)?;
let addrs = transport_addrs(transaction)?;
if addrs.contains(&configured_addr) {
return Ok(None);
}
let Some(new_addr) = addrs.into_iter().next() else {
return Ok(None);
};
transaction.execute(
"UPDATE config SET value=? WHERE keyname='configured_addr'",
(&new_addr,),
)?;
Ok(Some(new_addr))
}
/// Adds transport entry to the `transports` table with empty configuration.
pub async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
context.sql
context.sql.transaction(|transaction| {
transaction
.execute(
"INSERT INTO transports (addr, entered_param, configured_param) VALUES (?, ?, ?)",
(
@@ -746,8 +711,26 @@ pub async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
serde_json::to_string(&EnteredLoginParam{addr: addr.to_string(), ..Default::default()})?,
format!(r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#)
),
).with_context(|| format!("Failed to insert pseudo transport for {addr:?}"))?;
update_configured_addr(transaction)?;
Ok(())
}).await
}
/// Update deprecated `configured_addr` config so it points to the first transport.
///
/// `configured_addr` is not used anymore,
/// but old core (2026-11) fails when importing a backup without it,
/// so we need to set it for backwards compatibility.
pub fn update_configured_addr(transaction: &rusqlite::Transaction) -> Result<()> {
transaction
.execute(
"INSERT OR REPLACE INTO config (keyname, value)
SELECT 'configured_addr', addr
FROM transports ORDER BY id LIMIT 1",
(),
)
.await.with_context(|| format!("Failed to insert pseudo transport for {addr:?}"))?;
.context("Failed to update configured_addr")?;
Ok(())
}
-91
View File
@@ -197,10 +197,6 @@ async fn test_delete_transport() -> Result<()> {
.evtracker
.get_matching(|e| matches!(e, EventType::TransportsModified))
.await;
assert_eq!(
alice.get_config(Config::ConfiguredAddr).await?.as_deref(),
Some("alice@otherprovider.com")
);
sync_and_check_recipients(alice, alice2, "alice@otherprovider.com").await;
check_addrs(
@@ -226,50 +222,6 @@ async fn test_delete_transport() -> Result<()> {
Ok(())
}
/// Tests that selecting sending transport by setting "configured_addr" does not
/// send the sync message, is not synchronized between devices even if sync message is forced,
/// and does not bump sending transport `add_timestamp`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_configured_addr_synchronization() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
for a in [alice, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
a.set_config_bool(Config::BccSelf, true).await?;
}
let addr = "alice@otherprovider.com";
add_dummy_transport(alice, addr).await?;
send_sync_transports(alice).await?;
sync_and_check_recipients(alice, alice2, &format!("{addr} alice@example.org")).await;
// Selects `addr` on `alice` as the sending transport
// and syncs the transport update to `alice2`,
// whose own sending transport must stay unchanged.
let old_timestamp = add_timestamp(alice2, addr).await;
let alice2_primary = alice2.get_config(Config::ConfiguredAddr).await?;
alice.set_config(Config::ConfiguredAddr, Some(addr)).await?;
assert_eq!(add_timestamp(alice, addr).await, old_timestamp);
send_sync_transports(alice).await?;
alice.send_sync_msg().await?.unwrap();
let sync_msg = alice.pop_sent_msg().await;
assert_eq!(sync_msg.recipients, format!("alice@example.org {addr}"));
// The sync message comes from the new sending address,
// which must not make `alice2` adopt it as its own sending address.
assert!(sync_msg.payload.contains(&format!("From: <{addr}>")));
alice2.recv_msg_trash(&sync_msg).await;
// add_timestamp must not change.
assert_eq!(add_timestamp(alice2, addr).await, old_timestamp);
assert_eq!(
alice2.get_config(Config::ConfiguredAddr).await?,
alice2_primary
);
Ok(())
}
/// Tests that `sync_transports()` requests an IO restart
/// if and only if it modified anything.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -288,14 +240,6 @@ async fn test_sync_transports_requests_io_restart() -> Result<()> {
Ok(())
}
async fn add_timestamp(t: &TestContext, addr: &str) -> i64 {
t.sql
.query_get_value("SELECT add_timestamp FROM transports WHERE addr=?", (addr,))
.await
.unwrap()
.unwrap()
}
/// Tests that removing the last transport keeps it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_removing_last_transport() -> Result<()> {
@@ -308,10 +252,6 @@ async fn test_removing_last_transport() -> Result<()> {
};
sync_transports(alice, &[], std::slice::from_ref(&removed)).await?;
assert_eq!(alice.count_transports().await?, 1);
assert_eq!(
alice.get_config(Config::ConfiguredAddr).await?.as_deref(),
Some("alice@otherprovider.com")
);
let removed = RemovedTransportData {
addr: "alice@otherprovider.com".to_string(),
@@ -319,37 +259,6 @@ async fn test_removing_last_transport() -> Result<()> {
};
sync_transports(alice, &[], std::slice::from_ref(&removed)).await?;
assert_eq!(alice.count_transports().await?, 1);
assert_eq!(
alice.get_config(Config::ConfiguredAddr).await?.as_deref(),
Some("alice@otherprovider.com")
);
Ok(())
}
/// Tests which transport is elected for sending.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_maybe_update_sending_transport() -> Result<()> {
let t = &TestContext::new_alice().await;
add_dummy_transport(t, "alice@one.com").await?;
assert_eq!(
t.sql.transaction(maybe_update_sending_transport).await?,
None
);
t.sql
.execute(
"DELETE FROM transports WHERE addr=?",
("alice@example.org",),
)
.await?;
assert_eq!(
t.sql
.transaction(maybe_update_sending_transport)
.await?
.as_deref(),
Some("alice@one.com")
);
Ok(())
}
+31
View File
@@ -0,0 +1,31 @@
From: Bob <bob@example.net>
Subject: Message with invalid HTML mime types
To: alice@example.org
Message-ID: <html-html-test@localhost>
Date: Thu, 17 Dec 2020 15:38:45 +0100
MIME-Version: 1.0
Content-Type: multipart/mixed;
boundary="------------MESSAGE_BOUNDARY"
Content-Language: en-US
Chat-Version: 1.0
This is a multi-part message in MIME format.
--------------MESSAGE_BOUNDARY
Content-Type: html/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
This should be ignored.
--------------MESSAGE_BOUNDARY
Content-Type: html/html; charset=utf-8
Content-Transfer-Encoding: 7bit
And this should be ignored too.
--------------MESSAGE_BOUNDARY
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
Here is a text part.
--------------MESSAGE_BOUNDARY--