Compare commits

..
Author SHA1 Message Date
link2xt 6d7f0ed849 recode animated webp avatars 2026-10-02 19:57:38 +00:00
link2xt 5a3d79dc12 fix: do not reencode animated WebPs into JPEG
Reencoding animated WebPs into JPEG loses all frames except the first one.
2026-10-02 16:05:13 +00:00
link2xt 9578c2c054 refactor: do not guess image format from file extension on I/O error
`ImageReader::with_guessed_format()` returns I/O errors, meaning the file is likely not readable.
If we wanted to check for the case when file format was not guessed,
the correct way would be to check if imgreader.format() returns `None`.
All the widely used formats (JPEG, PNG, GIF, WebP) are guessable from the first bytes,
so the code is not worth fixing as we cannot test it.
2026-10-02 15:51:08 +00:00
link2xt 9e2d668490 refactor(deltachat-repl): move all commands to cmdline.rs
Also replaced unnecessary `ExitResult` and `should_continue` indirection with a simple `break` on "quit"/"exit" command.
2026-10-02 15:49:31 +00:00
link2xt d18718d2b9 chore: move deltachat-time to dev dependencies
It is only used for tests.
2026-10-02 15:28:12 +00:00
holger krekel 77bb77c33e refactor: take securejoin addresses from transport list
part of the effort to get rid of primary address conceptions in core.
2026-10-02 15:19:10 +02:00
link2xt 1143317fd1 feat: add context to HTTP(S) proxy connection errors
We already have context for HTTP(S) "CONNECT" command failures,
"Failed to connect to SOCKS5 proxy" and "Failed to connect to Shadowsocks proxy",
but when TCP connection to HTTP(S) fails the error is bubbled up as is.

This resulted in confusing "All connection attempts failed: Connection to host:port failed: ..." error
when configuring a relay, without any indication that "host:port" is the proxy address.
2026-10-02 01:26:54 +00:00
dependabot[bot] 83211e440e chore(cargo): bump dirs from 6.0.0 to 7.0.0
Bumps dirs from 6.0.0 to 7.0.0.

---
updated-dependencies:
- dependency-name: dirs
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 01:23:58 +00:00
dependabot[bot] 7cdfb234a3 chore(cargo): bump hyper-util from 0.1.20 to 0.1.21
Bumps [hyper-util](https://github.com/hyperium/hyper-util) from 0.1.20 to 0.1.21.
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.20...v0.1.21)

---
updated-dependencies:
- dependency-name: hyper-util
  dependency-version: 0.1.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 01:21:56 +00:00
dependabot[bot] 2a9bc732bf chore(deps): bump taiki-e/install-action from 2.86.7 to 2.87.20
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.86.7 to 2.87.20.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/b6ff580856c41316412a0b9b60540fbc6f8c82cc...9983c65e42da123ff25d1f78505eb6de315aa172)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.87.20
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:52:51 +00:00
dependabot[bot] 90387fdbea chore(cargo): bump thiserror from 2.0.20 to 2.0.21
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.20 to 2.0.21.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.20...2.0.21)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:52:30 +00:00
dependabot[bot] 29006e47c5 chore(cargo): bump tokio-rustls from 0.26.4 to 0.26.5
Bumps [tokio-rustls](https://github.com/rustls/tokio-rustls) from 0.26.4 to 0.26.5.
- [Release notes](https://github.com/rustls/tokio-rustls/releases)
- [Commits](https://github.com/rustls/tokio-rustls/compare/v/0.26.4...v/0.26.5)

---
updated-dependencies:
- dependency-name: tokio-rustls
  dependency-version: 0.26.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:52:14 +00:00
dependabot[bot] 79bf0c79a5 chore(cargo): bump hyper from 1.10.1 to 1.11.1
Bumps [hyper](https://github.com/hyperium/hyper) from 1.10.1 to 1.11.1.
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: hyper
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:51:48 +00:00
dependabot[bot] e5d1bf7504 chore(deps): bump cachix/install-nix-action from 31.11.0 to 31.11.1
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.11.0 to 31.11.1.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md)
- [Commits](https://github.com/cachix/install-nix-action/compare/630ae543ea3a38a9a4166f03376c02c50f408342...13d8dd58da0234aa297dedd986986ccb8e7f3e24)

---
updated-dependencies:
- dependency-name: cachix/install-nix-action
  dependency-version: 31.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:51:29 +00:00
dependabot[bot] 32d40cbb14 chore(cargo): bump uuid from 1.25.0 to 1.26.1
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.25.0 to 1.26.1.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/1.25.0...v1.26.1)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.26.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:51:10 +00:00
dependabot[bot] 4e83357371 chore(cargo): bump mailparse from 0.16.1 to 0.17.0
Bumps [mailparse](https://github.com/staktrace/mailparse) from 0.16.1 to 0.17.0.
- [Commits](https://github.com/staktrace/mailparse/compare/v0.16.1...v0.17.0)

---
updated-dependencies:
- dependency-name: mailparse
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:44:05 +00:00
dependabot[bot] 88deea5b18 chore(deps): bump zizmorcore/zizmor-action from 0.6.2 to 0.6.4
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.2 to 0.6.4.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](https://github.com/zizmorcore/zizmor-action/compare/3dc1ecc9bcb9e94e9b2c709687979e1298497054...cc914d7f3750a2d13d75c7f184a1060aa0e9d482)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:43:27 +00:00
dependabot[bot] 8883e5ada0 chore(cargo): bump syn from 3.0.4 to 3.0.6
Bumps [syn](https://github.com/dtolnay/syn) from 3.0.4 to 3.0.6.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/3.0.4...3.0.6)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 3.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:43:09 +00:00
dependabot[bot] ca2a40e210 chore(cargo): bump smallvec from 1.15.2 to 1.16.1
Bumps [smallvec](https://github.com/servo/rust-smallvec) from 1.15.2 to 1.16.1.
- [Release notes](https://github.com/servo/rust-smallvec/releases)
- [Commits](https://github.com/servo/rust-smallvec/compare/v1.15.2...v1.16.1)

---
updated-dependencies:
- dependency-name: smallvec
  dependency-version: 1.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 00:42:23 +00:00
link2xt 1cd2957a4d chore: remove unused "sdp" dependency 2026-10-01 14:51:36 +00:00
link2xt 4d57b8bf32 feat: do not send unencrypted MDNs 2026-10-01 14:50:13 +00:00
link2xt e203ae9571 refactor: replace MimeFactory::from_mdn() with a standalone mimefactory::mdn()
This factors out all MDN-related code that was previously inside `MimeFactory`
into a `mimefactory::mdn` function that constructs `QueuedMail` directly,
similarly to `mimefactory::keyupdate_message`.

Removing MDN-related code from MimeFactory also means that MimeFactory only handles non-MDN messages now
and we don't have to `match` on `MimeFactory.loaded` everywhere to access `msg` and `chat`.

Unlike `MimeFactory::from_mdn`, `mimefactory::mdn` does not need to fill `MimeFactory.from_addr`.
This removes one `get_primary_self_addr()` call. `mimefactory::mdn` also does not need to fill
various fields that are never used for MDNs such as `past_members`.
2026-10-01 14:50:13 +00:00
link2xt a37899b365 refactor: factor add_headers_to_part() out of mimefactory::add_headers_to_protected_part() 2026-10-01 14:50:13 +00:00
link2xt 3173487c9f fix: add headers to unencrypted MDNs
We don't want to wrap MDNs in multipart/mixed,
but we still want to add headers such as Date
like for normal unencrypted messages.
2026-10-01 14:50:13 +00:00
link2xt aade549ccc refactor: get rid of dead sticker-related code in check_or_recode_to_size()
It looks like the function is only called for File and Image viewtypes,
so there is no way to get into the Sticker branch
that checks the corners for transparency.
2026-10-01 13:39:12 +00:00
link2xt 736979b451 feat: do not request MDNs for unencrypted messages 2026-09-29 16:02:09 +00:00
holger krekel 5a882b0aca fix: delete handled securejoin messages on all relays
A join request is sent to every relay of an inviter, but the answering
inviter device only deleted the copy it downloaded. Another device of the inviter
fetching a different relay later answered the request again, re-adding
members who had left or been removed in the meantime.
2026-09-29 14:51:29 +02:00
link2xt 1e36fb74be chore: remove some unwrap() calls 2026-09-28 15:40:37 +00:00
biørn ebf1c87483 feat: re-add "second device added" message if deleted soon after transfer (#8758)
this PR makes it easier to spot unwanted "add second device" actions, if
the unlocked device is left unattended and/or a system lock is not in
place (so cannot be checked directly before doing the "add second
device" action).

already today, the sending device gets the message "ℹ️ Profile
transferred to your second device", which, however, can be deleted.

with this PR, if the message is deleted soon after the transfer, it
first looks as if this was successful. however, the message is re-added
later, at the first housekeeping at least an hour after the transfer.
deleting the message after that is final, so users can still get rid of
it.

It is clear that this does not catch all eventualities, it is
best-effort. e.g. it is known that subsequent "add second device"
attempts remove checks of previous ones.
First line of defense is device locking and asking for secret explicitly
before adding a second device.

previous discussions about that at
https://github.com/chatmail/core/issues/4303
2026-09-28 14:49:58 +02:00
d2weber 172ebc08f9 fix: regenerate deltachat.pc if env changed (#8752)
Paths in deltachat.pc are passed via env variables to build.rs. With
this fix, the pc will be regenerated if these env variables change.

This came up when building via CMake: the paths depend on
CMAKE_INSTALL_PREFIX, if the CMAKE_INSTALL_PREFIX changes during a
reconfigure, deltachat.pc should have the corresponding paths adjusted.
2026-09-28 11:01:22 +02:00
missytake eeb9337a56 chore: add chatmail.cc (#8756) 2026-09-26 23:00:35 +02:00
holger krekel a1eb593d81 chore: add chatmail.au 2026-09-26 19:34:15 +02:00
link2xt 7e070efc28 chore: enable clippy::unnecessary_wraps 2026-09-25 11:10:23 +00:00
link2xt 5d3145d165 chore: fix clippy::string_lit_as_bytes suggestions 2026-09-25 11:10:23 +00:00
link2xt 1605b971ba chore: fix clippy::redundant_clone suggestions 2026-09-25 11:10:23 +00:00
Hocuriandl 22578ea4b6 fix: Correctly percent-encode addresses in securejoin invite codes (#8747)
This PR fixes how addresses in securejoin invite codes (i.e. QR codes
and invite links) are percent-encoded.

Before this PR,
- `@` in the addresses was percent-encoded. This makes the invite code
harder to read, and is not necessary; at least, every software we tested
correctly handled links that contain `@` (Signal, WhatsApp, Telegram,
Thunderbird, Delta Chat on Android, iOS, and Desktop)
- _But_, the first address in the `r=` parameter of a securejoin link
was not percent-encoded at all. This was a sneaky bug caused by using
the `reduce` function; I always find it hard to follow code that uses
`reduce` (and similar functions like `fold`), and apparently others have
the same problem since neither @j-g00da nor @link2xt noticed the problem
when implementing & reviewing the PR that introduced the bug.

With this PR:
- `@` is allowed in addresses in securejoin invite codes
- all addresses are percent-encoded
- `reduced` is not used anymore

---------

Co-authored-by: l <link2xt@testrun.org>
2026-09-24 20:58:11 +00:00
link2xt 076f83f320 chore: reduce noise created by key-contact migration
Stop logging the time migration takes
and don't log anything when migration runs on a fresh database
and only creates empty tables.

Without these changes every time profile is created,
the following info lines are logged:

    src/sql/migrations.rs:35: Starting key-contact transition.
    src/sql/migrations.rs:82: Not yet configured, no need to migrate key-contacts
    src/sql/migrations.rs:1942: key-contacts migration took 1.143721ms in total.
2026-09-24 16:25:22 +00:00
link2xt 8ab98019a7 feat(deltachat-repl): remove "reset" command
This is not a correct way to reset the database, it does not even clear the transports table
so account stays configured. If someone needs a fresh database, then REPL should be restarted
with a new path.
2026-09-23 12:52:17 +00:00
link2xt 76d664f9cb refactor!: stop saving legacy config values when configuring a transport
Users downgrading to UIs that load legacy config values is unlikely,
and cannot work correctly with multiple transports.

BREAKING CHANGE: core does not set deprecated config values `addr`, `mail_pw` etc. Use `list_transports()` API instead.
2026-09-23 12:13:40 +00:00
Hocuri f0e8e80894 test: Add basic tests for rendering unencrypted messages (#8736)
This adds two tests for rendering unencrypted messages.

As a follow-up to,
https://github.com/chatmail/core/pull/8543#issuecomment-5207509885, I
would like to make sure that sensitive headers don't go into unencrypted
messages. While looking into this, I noticed that we don't have any
tests at all yet that test how exactly an unencrypted message is
rendered, only for specific parts.

Right now, we're sending `Chat-Disposition-Notification-To:
alice@example.org` in unencrypted messages, I think it should be
removed, I'll make a follow-up PR with a whitelist of allowed
unencrypted headers.
2026-09-23 13:09:52 +02:00
link2xt 3756537a13 api: add PinnedMessagesChanged event 2026-09-23 11:05:50 +00:00
link2xt 2ad4301f44 test: move pinned messages tests into pinned_messages_tests module
This is similar to how it is for crate::transport::transport_tests
No changes made to the code of the tests in this commit.
2026-09-22 18:54:04 +00:00
link2xt 6f4587a030 chore(release): bump version to 2.63.0-dev 2026-09-22 17:51:52 +00:00
link2xt c41cac76d2 chore(release): prepare for 2.62.0 2026-09-22 17:40:01 +00:00
link2xt 3de65e4f0e api!: get rid of Qr::FprWithoutAddr and Qr::FprMismatch variants
We don't look at the address in OPENPGP4FPR QR codes anymore.
If we cannot find a contact by fingerprint,
then we return an error without trying to look up
the contact by address.

Scanning OPENPGP4FPR can now only return FprOk
if matching key-contact is found, or an error.

BREAKING CHANGE: removed DC_QR_FPR_WITHOUT_ADDR and DC_QR_FPR_MISMATCH constants from CFFI
BREAKING CHANGE: removed FprWithoutAddr and FprMismatch variants from JSON-RPC QrObject returned by check_qr
2026-09-22 13:17:28 +00:00
Hocuri 2a5058a308 api: Remove unused ImapMessageMoved event 2026-09-22 15:05:36 +02:00
Hocuri b79125b947 refactor: Remove code that moves messages between IMAP folders 2026-09-22 15:05:36 +02:00
biørn ed647aa49c fix: blocked or email contacts contacts are never recently seen or old (#8733)
this PR ensures blocked contacts or email contacts never get some
special treatment in the UI

- showing email contacts as recently seen (so with a dot) or as old is a
bit weird, this is not what is expected for email. also, the line for
"old" might be used to show the address

- for blocked contacts, this is mainly about the list of blocked
contacts - there, we do not want to higlight the contact somehow

targets https://github.com/chatmail/core/issues/8731
2026-09-22 12:40:53 +00:00
Hocuri 8858c01f3f feat: Don't show "member added" messages in channels (#8730)
Fix https://github.com/chatmail/core/issues/8606

"Member added" messages in channels clutter the UI because sometimes, a
lot of recipients join, and it is not that interesting for the channel
owner who joined when exactly. Also, the correct string would be "XY
subscribed to your channel", but it seems easier and better to just
remove these messages.

There are 3 places in the code that can add a member to a channel:
- `add_contact_to_chat_ext()`, which is called during securejoin
- Seeing a member-addition message from our own second device in
receive_imf
- A sync message telling us about a member addition which we missed
2026-09-22 13:33:55 +02:00
Hocuri 54b093d89f fix: prefer login errors over connection errors in the log message (#8727)
fix https://github.com/chatmail/core/issues/8534
2026-09-22 13:32:01 +02:00
holger krekel b6f116d26a fix: keep a message read status if a failure arrives later
also simplifies tests to use a shared helper for receiving an mdn
2026-09-22 13:07:31 +02:00
Hocuri 362cc76c83 fix: Don't notify about a reaction sent by a blocked contact (#8729)
Fixes https://github.com/chatmail/core/issues/8692: Now the user does
not get notifications when a blocked contact reacts to some message.

What this PR does not change is what happens when being in a group with
a blocked contact; it only fixes this obvious bug.

I checked that the test fails without the fix.
2026-09-21 22:09:20 +02:00
link2xt aa0800b6e0 chore: bump version to 2.62.0-dev 2026-09-21 20:07:23 +00:00
link2xt 5a5597e990 chore(release): prepare for 2.61.0 2026-09-21 19:45:37 +00:00
B. Petersen 94cc8a3858 api!: replace was_seen_recently by freshness in contact object
BREAKING CHANGE: use contact's `freshness` instead of `seen_recently`
2026-09-21 21:30:56 +02:00
B. Petersen 6b62fed6fa feat: a single NDN does not mark a group message as failed 2026-09-21 21:30:56 +02:00
biørn b766a9a8fa feat: do not mark message as failed for which we got a read receipt before
this PR avoids marking a message as being failed if we already received
an read receipt for it.

background: esp. with multi relay it will easily happen that we get some
NDN for some relays. this does not mean the message at a whole was
failing, esp. if we got a read receipt (MDN) before

with https://github.com/chatmail/core/pull/8722 we do no longer process
NDN in groups, so the change of this PR affects one-to-one chats only
2026-09-21 17:04:57 +00:00
holger krekel 3d3a6352e9 fix: don't emit configure progress events during background relay additions
reported by bjoern while testing the branch on iOS: the background addition
would start fast enough to still show up in the configure-progress bar
of the initial onboarding screen. This commit should fix that.
2026-09-21 18:51:36 +02:00
holger krekel 0eddb98354 feat: try fasted relays to attempt first configure on 2026-09-21 18:51:36 +02:00
holger krekel 72e711e59a api: add init_transports() for multi-relay onboarding
Closes #8693 and supsersedes #8707 from which the API
and some overall shape of this commit is inspired.

UIs call `init_transports(None)` or `init_transports(qr)`
to initialize a first transport on the fresh profile,
with more transports added in the background later.
2026-09-21 18:51:36 +02:00
link2xt a144a450e1 api!: remove addr field from Account objects in JSON-RPC APIs
`list_transports()` should be used instead.
Previously `addr` field might have even not correspond to any transport
as it was `Config::Addr` and not `Config::ConfiguredAddr`.

BREAKING CHANGE: list_transports() should be used instead.
2026-09-21 14:38:25 +00:00
holger krekel b87cd8937a refactor: substitute configure progress macro with simple function call
the macro was introduced in 2019 in
https://github.com/chatmail/core/commit/6ced6ac23b8398cea4ab8c02610af9be4d3d6974
but isn't neccessary
2026-09-21 11:55:59 +02:00
101 changed files with 2700 additions and 1905 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ jobs:
cache-bin: false
- name: Install nextest
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc
uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172
with:
tool: nextest
+7 -7
View File
@@ -34,7 +34,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -58,7 +58,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
@@ -82,7 +82,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -106,7 +106,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
@@ -157,7 +157,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -181,7 +181,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
@@ -208,7 +208,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v7
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- run: nix fmt flake.nix nix/ -- --check
build:
@@ -85,7 +85,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -106,5 +106,5 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- run: nix build .#${{ matrix.installable }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary
+2 -2
View File
@@ -41,7 +41,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -63,7 +63,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
+109
View File
@@ -1,5 +1,112 @@
# Changelog
## [2.62.0] - 2026-09-22
### API-Changes
- Remove unused ImapMessageMoved event.
- [**breaking**] get rid of `Qr::FprWithoutAddr` and `Qr::FprMismatch` variants.
- removed `DC_QR_FPR_WITHOUT_ADDR` and `DC_QR_FPR_MISMATCH` constants from CFFI
- removed FprWithoutAddr and FprMismatch variants from JSON-RPC QrObject returned by `check_qr`
### Features / Changes
- Don't show "member added" messages in channels ([#8730](https://github.com/chatmail/core/pull/8730)).
### Fixes
- Don't notify about a reaction sent by a blocked contact ([#8729](https://github.com/chatmail/core/pull/8729)).
- keep a message read status if a failure arrives later.
- prefer login errors over connection errors in the log message ([#8727](https://github.com/chatmail/core/pull/8727)).
- blocked or email contacts contacts are never recently seen or old ([#8733](https://github.com/chatmail/core/pull/8733)).
### Refactor
- Remove code that moves messages between IMAP folders.
## [2.61.0] - 2026-09-21
### API-Changes
- add `init_transports()` for multi-relay onboarding.
- add JSON-RPC API `is_sending_finished()`.
- [**breaking**] remove verification methods from the FFI and JSON-RPC APIs.
- `dc_contact_is_verified()` and `dc_contact_get_verifier_id()` are removed.
- the JSON-RPC Contact object loses the `isVerified` and `verifierId` fields. A bot reading `snapshot.is_verified` now gets an `AttributeError` at runtime.
- the Python bindings lose `Contact.is_verified()` and `Contact.get_verifier()`.
- `DC_STR_CONTACT_VERIFIED` (35) is removed, so UIs should stop registering a translation for it. A stock id core does not know is logged and otherwise ignored, so an un-updated client keeps working.
- [**breaking**] remove default value for "addr" config.
- [**breaking**] remove `addr` field from Account objects in JSON-RPC APIs.
- `list_transports()` should be used instead.
- [**breaking**] remove `is_chatmail` and the XCHATMAIL capability.
- `is_chatmail` is no longer a known config key.
- [**breaking**] remove `Contact.get_name_n_addr()` and related APIs.
- `dc_contact_get_name_n_addr()` CFFI is removed
- JSON-RPC contact objects don't have nameAndAddr field anymore
- [**breaking**] replace `was_seen_recently` by `freshness` in contact object.
- use contact's `freshness` instead of `seen_recently`
### Fixes
- always emit `AccountsBackgroundFetchDone`.
- make `background_fetch` not wait on or trigger SMTP connections.
- do not send a sync message when changing `configured_addr`.
- emit `SmtpMessageSent` event after deleting the message from SMTP queue.
- don't use extra STUN nine server for fallback.
- use `max_smtp_rcpt_to` chunking for the actual transport we are sending from.
- use correct `From` address when sending MDNs.
- use correct address for Bcc-self in unencrypted mails.
- don't emit configure progress events during background relay additions.
### Features / Changes
- better quality of image recoding ([#8682](https://github.com/chatmail/core/pull/8682)).
- perform background fetch from all transports.
- queue messages for SMTP before encryption.
- [**breaking**] stop tracking contact verification.
- the statistics JSON sent to the self-reporting-bot on Android changes: Contacts have `encrypted` instead of `verified` and lose `transitive_chain` properties and message stats have `encrypted` instead of `verified` and `unverified_encrypted`, and securejoin invites lose `already_verified`. The collecting bot stores incoming reports verbatim but analysis will have to make sense of older and newer reports.
- remove last usage of XDELTAPUSH capability.
- base server-side message deletion on `force_encryption`.
- do not restart I/O when setting `configured_addr`.
- do not use ConfiguredAddr when connecting to SMTP.
- mark autorelays for relay operators ([#8701](https://github.com/chatmail/core/pull/8701)).
- try fasted relays to attempt first configure on.
- do not mark message as failed for which we got a read receipt before.
- a single NDN does not mark a group message as failed.
### Documentation
- update `sys.msgsize_max_recommended` documentation.
- add hint about how to reset an invitation ([#8160](https://github.com/chatmail/core/pull/8160)).
- mention `get_app_version()` API in the changelog for 2.59.0.
- remove `protect_autocrypt` setting.
### Miscellaneous Tasks
- cleanup "primary" wording in comments.
- update rustls to 0.23.45.
- fix nightly "cargo" warnings.
- fix some types in `deltachat_rpc_client`.
### Refactor
- add `Encryption.is_encrypted()`.
- separate QueuedEncryption.
- sql: disable double-quoted string literals.
- add smtp::queue module.
- substitute configure progress macro with simple function call.
### Tests
- cleanup `get_smtp_rows_for_msg()`.
- cross-core securejoin invites for every chat type.
- explicitly empty url in appversion updates ([#8702](https://github.com/chatmail/core/pull/8702)).
- do not talk about "inconsistent key state" in `test_securejoin_after_contact_resetup`.
- rename `test_aeap_transition_{0,1}` to `test_aeap_transition_{single,group}`.
- do not fetch all messages when `direct_imap` is created.
- `direct_imap`: always pass `mark_seen=False` to fetch().
- add pseudo transport explicitly rather than by setting ConfiguredAddr.
## [2.60.0] - 2026-09-11
### API-Changes
@@ -8794,3 +8901,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.58.0]: https://github.com/chatmail/core/compare/v2.57.0..v2.58.0
[2.59.0]: https://github.com/chatmail/core/compare/v2.58.0..v2.59.0
[2.60.0]: https://github.com/chatmail/core/compare/v2.59.0..v2.60.0
[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
Generated
+54 -75
View File
@@ -310,7 +310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79"
dependencies = [
"native-tls",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"url",
]
@@ -327,7 +327,7 @@ dependencies = [
"log",
"nom 8.0.0",
"pin-project",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
]
@@ -363,7 +363,7 @@ dependencies = [
"crc32fast",
"futures-lite",
"pin-project",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tokio-util",
]
@@ -459,7 +459,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -1327,7 +1327,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.61.0-dev"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1386,7 +1386,6 @@ dependencies = [
"regex",
"rusqlite",
"sanitize-filename",
"sdp",
"serde",
"serde_json",
"sha-1",
@@ -1399,7 +1398,7 @@ dependencies = [
"tempfile",
"testdir",
"textwrap",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tokio-io-timeout",
"tokio-rustls",
@@ -1435,7 +1434,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.61.0-dev"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1456,14 +1455,14 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc-bindings"
version = "2.61.0-dev"
version = "2.63.0-dev"
dependencies = [
"deltachat-jsonrpc",
]
[[package]]
name = "deltachat-repl"
version = "2.61.0-dev"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1479,7 +1478,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.61.0-dev"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1503,12 +1502,12 @@ name = "deltachat_derive"
version = "2.0.0"
dependencies = [
"quote",
"syn 3.0.4",
"syn 3.0.6",
]
[[package]]
name = "deltachat_ffi"
version = "2.61.0-dev"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1518,7 +1517,7 @@ dependencies = [
"num-traits",
"rand 0.9.4",
"serde_json",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"yerpc",
]
@@ -1677,9 +1676,9 @@ dependencies = [
[[package]]
name = "dirs"
version = "6.0.0"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
dependencies = [
"dirs-sys",
]
@@ -2222,7 +2221,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.6",
]
[[package]]
@@ -2465,7 +2464,7 @@ dependencies = [
"once_cell",
"rand 0.9.4",
"ring",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tinyvec",
"tokio",
"tracing",
@@ -2488,7 +2487,7 @@ dependencies = [
"rand 0.9.4",
"resolv-conf",
"smallvec",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tracing",
]
@@ -2654,9 +2653,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
dependencies = [
"atomic-waker",
"bytes",
@@ -2694,15 +2693,16 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http 1.1.0",
"http-body",
"httparse",
"hyper",
"libc",
"pin-project-lite",
@@ -3045,7 +3045,7 @@ dependencies = [
"strum 0.26.2",
"stun-rs",
"surge-ping",
"thiserror 2.0.20",
"thiserror 2.0.21",
"time",
"tokio",
"tokio-stream",
@@ -3070,7 +3070,7 @@ dependencies = [
"ed25519-dalek",
"rand_core 0.6.4",
"serde",
"thiserror 2.0.20",
"thiserror 2.0.21",
"url",
]
@@ -3112,7 +3112,7 @@ dependencies = [
"rand_core 0.6.4",
"serde",
"serde-error",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tokio-util",
"tracing",
@@ -3157,7 +3157,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2 0.5.9",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
@@ -3177,7 +3177,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
@@ -3232,7 +3232,7 @@ dependencies = [
"sha1",
"strum 0.26.2",
"stun-rs",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tokio-rustls",
"tokio-util",
@@ -3450,9 +3450,9 @@ checksum = "4c942e8a4b83f9351236c1e531ea9fa0237913d63c7fc36818430e0128a1ddf3"
[[package]]
name = "mailparse"
version = "0.16.1"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f"
checksum = "8469955699e572455f8e1a40689e07ab309911d659632f65062598192254c091"
dependencies = [
"charset",
"data-encoding",
@@ -3717,7 +3717,7 @@ dependencies = [
"log",
"netlink-packet-core",
"netlink-sys",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -4206,7 +4206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
dependencies = [
"memchr",
"thiserror 2.0.20",
"thiserror 2.0.21",
"ucd-trie",
]
@@ -4382,7 +4382,7 @@ dependencies = [
"serde",
"sha1_smol",
"simple-dns",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
@@ -4780,7 +4780,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2 0.5.9",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tracing",
]
@@ -4801,7 +4801,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
@@ -5040,7 +5040,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -5444,18 +5444,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdp"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22c3b0257608d7de4de4c4ea650ccc2e6e3e45e3cd80039fcdee768bcb449253"
dependencies = [
"rand 0.9.4",
"substring",
"thiserror 1.0.69",
"url",
]
[[package]]
name = "sec1"
version = "0.7.3"
@@ -5561,7 +5549,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.6",
]
[[package]]
@@ -5717,7 +5705,7 @@ dependencies = [
"shadowsocks-crypto",
"socket2 0.5.9",
"spin 0.10.1",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
"tokio-tfo",
"trait-variant",
@@ -5829,9 +5817,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.15.2"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "smawk"
@@ -6014,15 +6002,6 @@ dependencies = [
"rand 0.9.4",
]
[[package]]
name = "substring"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86"
dependencies = [
"autocfg",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6069,9 +6048,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.4"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
@@ -6215,11 +6194,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl 2.0.20",
"thiserror-impl 2.0.21",
]
[[package]]
@@ -6235,13 +6214,13 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
"syn 3.0.6",
]
[[package]]
@@ -6361,9 +6340,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
version = "0.26.4"
version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
dependencies = [
"rustls",
"tokio",
@@ -6763,9 +6742,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.25.0"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -7429,7 +7408,7 @@ dependencies = [
"futures",
"log",
"serde",
"thiserror 2.0.20",
"thiserror 2.0.21",
"windows 0.59.0",
"windows-core 0.59.0",
]
+3 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.61.0-dev"
version = "2.63.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
@@ -36,7 +36,6 @@ strip = true
[dependencies]
deltachat_derive = { path = "./deltachat_derive" }
deltachat-time = { path = "./deltachat-time" }
deltachat-contact-tools = { workspace = true }
format-flowed = { path = "./format-flowed" }
ratelimit = { path = "./deltachat-ratelimit" }
@@ -87,7 +86,6 @@ rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
sanitize-filename = { workspace = true }
sdp = "0.17.1"
serde_json = { workspace = true }
serde = { workspace = true, features = ["derive"] }
sha-1 = "0.10"
@@ -115,6 +113,7 @@ webpki-roots = "0.26.8"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.8.1", features = ["async_tokio"] }
deltachat-time = { path = "./deltachat-time" }
futures-lite = { workspace = true }
log = { workspace = true }
nu-ansi-term = { workspace = true }
@@ -189,7 +188,7 @@ futures = "0.3.32"
futures-lite = "2.6.1"
libc = "0.2"
log = "0.4"
mailparse = "0.16.1"
mailparse = "0.17.0"
nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.9"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.61.0-dev"
version = "2.63.0-dev"
description = "Deltachat FFI"
edition = "2024"
license = "MPL-2.0"
+3 -3
View File
@@ -21,9 +21,9 @@ fn main() {
url = env::var("CARGO_PKG_HOMEPAGE").unwrap_or_else(|_| "".to_string()),
version = env::var("CARGO_PKG_VERSION").unwrap(),
libs_priv = libs_priv,
prefix = env::var("PREFIX").unwrap_or_else(|_| "/usr/local".to_string()),
libdir = env::var("LIBDIR").unwrap_or_else(|_| "/usr/local/lib".to_string()),
includedir = env::var("INCLUDEDIR").unwrap_or_else(|_| "/usr/local/include".to_string()),
prefix = option_env!("PREFIX").unwrap_or_else(|| "/usr/local"),
libdir = option_env!("LIBDIR").unwrap_or_else(|| "/usr/local/lib"),
includedir = option_env!("INCLUDEDIR").unwrap_or_else(|| "/usr/local/include"),
);
fs::create_dir_all(target_path.join("pkgconfig")).unwrap();
+49 -23
View File
@@ -2455,8 +2455,6 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_ASK_VERIFYGROUP 202 // text1=groupname
#define DC_QR_ASK_VERIFYBROADCAST 204 // text1=broadcast name
#define DC_QR_FPR_OK 210 // id=contact
#define DC_QR_FPR_MISMATCH 220 // id=contact
#define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint
#define DC_QR_ACCOUNT 250 // text1=domain
#define DC_QR_BACKUP2 252
#define DC_QR_BACKUP_TOO_NEW 255
@@ -2497,13 +2495,6 @@ void dc_stop_ongoing_process (dc_context_t* context);
* ask the user if they want to start chatting;
* if so, call dc_create_chat_by_contact_id().
*
* - DC_QR_FPR_MISMATCH with dc_lot_t::id=Contact ID:
* scanned fingerprint does not match last seen fingerprint.
*
* - DC_QR_FPR_WITHOUT_ADDR with dc_lot_t::text1=Formatted fingerprint
* the scanned QR code contains a fingerprint but no e-mail address;
* suggest the user to establish an encrypted connection first.
*
* - DC_QR_ACCOUNT dc_lot_t::text1=domain:
* ask the user if they want to create an account on the given domain,
* if so, call dc_set_config_from_qr() and then dc_configure().
@@ -4912,6 +4903,38 @@ uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg);
int dc_msg_is_pinned (const dc_msg_t* msg);
/**
* @defgroup DC_FRESHNESS DC_FRESHNESS
*
* These constants describe the freshness of a contact,
* as returned by dc_contact_get_freshness().
*
* @addtogroup DC_FRESHNESS
* @{
*/
/**
* Contact shall not be highlighted, e.g. neither shown with a "seen recently" dot
* nor with a "not seen for a long time" hint.
*/
#define DC_FRESHNESS_NORMAL 0
/**
* Contact was seen recently, the UI shall highlight it e.g. with a little green dot on the avatar.
*/
#define DC_FRESHNESS_RECENTLY_SEEN 1
/**
* Contact was not seen for a long time, the UI shall highlight it e.g. with a string
* below the contact name (e.g. "Seen 2 months ago").
*/
#define DC_FRESHNESS_OLD 2
/**
* @}
*/
/**
* @class dc_contact_t
*
@@ -5084,18 +5107,18 @@ int64_t dc_contact_get_last_seen (const dc_contact_t* contact);
/**
* Check if the contact was seen recently.
* Get the contact's freshness.
*
* The UI shall hightlight contacts that are recently seen by a little green dot on the avatar
* and contacts that were not seen for a long time by a string below the contact name (e.g. "Seen 2 months ago")
*
* The UI may highlight these contacts,
* eg. draw a little green dot on the avatars of the users recently seen.
* DC_CONTACT_ID_SELF and other special contact IDs are defined as never seen recently (they should not get a dot).
* To get the time a contact was seen, use dc_contact_get_last_seen().
*
* @memberof dc_contact_t
* @param contact The contact object.
* @return 1=contact seen recently, 0=contact not seen recently.
* @return One of the @ref DC_FRESHNESS constants.
*/
int dc_contact_was_seen_recently (const dc_contact_t* contact);
int dc_contact_get_freshness (const dc_contact_t* contact);
/**
@@ -5907,14 +5930,6 @@ void dc_event_unref(dc_event_t* event);
*/
#define DC_EVENT_IMAP_MESSAGE_DELETED 104
/**
* Emitted when a message was successfully moved on IMAP.
*
* @param data1 0
* @param data2 (char*) Info string in English language.
*/
#define DC_EVENT_IMAP_MESSAGE_MOVED 105
/**
* Emitted before going into IDLE on the Inbox folder.
*
@@ -6163,6 +6178,17 @@ void dc_event_unref(dc_event_t* event);
#define DC_EVENT_CHAT_DELETED 2023
/**
* The list of pinned messages for the chat has changed.
*
* Some message got pinned, or pinned message is unpinned or deleted.
*
* @param data1 (int) chat_id
* @param data2 (int) 0
*/
#define DC_EVENT_PINNED_MESSAGES_CHANGED 2024
/**
* Contact(s) created, renamed, blocked or deleted.
*
+10 -10
View File
@@ -493,7 +493,6 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::ImapConnected(_) => 102,
EventType::SmtpMessageSent(_) => 103,
EventType::ImapMessageDeleted(_) => 104,
EventType::ImapMessageMoved(_) => 105,
EventType::ImapInboxIdle => 106,
EventType::NewBlobFile(_) => 150,
EventType::DeletedBlobFile(_) => 151,
@@ -515,6 +514,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::ChatModified(_) => 2020,
EventType::ChatEphemeralTimerModified { .. } => 2021,
EventType::ChatDeleted { .. } => 2023,
EventType::PinnedMessagesChanged { .. } => 2024,
EventType::ContactsChanged(_) => 2030,
EventType::LocationChanged(_) => 2035,
EventType::ConfigureProgress { .. } => 2041,
@@ -560,7 +560,6 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
| EventType::ImapConnected(_)
| EventType::SmtpMessageSent(_)
| EventType::ImapMessageDeleted(_)
| EventType::ImapMessageMoved(_)
| EventType::ImapInboxIdle
| EventType::NewBlobFile(_)
| EventType::DeletedBlobFile(_)
@@ -589,7 +588,8 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
| EventType::MsgReadCountChanged { chat_id, .. }
| EventType::ChatModified(chat_id)
| EventType::ChatEphemeralTimerModified { chat_id, .. }
| EventType::ChatDeleted { chat_id } => chat_id.to_u32() as libc::c_int,
| EventType::ChatDeleted { chat_id }
| EventType::PinnedMessagesChanged { chat_id } => chat_id.to_u32() as libc::c_int,
EventType::ContactsChanged(id) | EventType::LocationChanged(id) => {
let id = id.unwrap_or_default();
id.to_u32() as libc::c_int
@@ -635,7 +635,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ImapConnected(_)
| EventType::SmtpMessageSent(_)
| EventType::ImapMessageDeleted(_)
| EventType::ImapMessageMoved(_)
| EventType::ImapInboxIdle
| EventType::NewBlobFile(_)
| EventType::DeletedBlobFile(_)
@@ -664,7 +663,8 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
| EventType::TransportsModified => 0,
| EventType::TransportsModified
| EventType::PinnedMessagesChanged { .. } => 0,
EventType::MsgsChanged { msg_id, .. }
| EventType::ReactionsChanged { msg_id, .. }
| EventType::IncomingReaction { msg_id, .. }
@@ -730,7 +730,6 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::ImapConnected(msg)
| EventType::SmtpMessageSent(msg)
| EventType::ImapMessageDeleted(msg)
| EventType::ImapMessageMoved(msg)
| EventType::NewBlobFile(msg)
| EventType::DeletedBlobFile(msg)
| EventType::Warning(msg)
@@ -766,7 +765,8 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::AccountsItemChanged
| EventType::IncomingCallAccepted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::TransportsModified => ptr::null_mut(),
| EventType::TransportsModified
| EventType::PinnedMessagesChanged { .. } => ptr::null_mut(),
EventType::IncomingCall {
place_call_info, ..
} => place_call_info.strdup(),
@@ -4034,13 +4034,13 @@ pub unsafe extern "C" fn dc_contact_get_last_seen(contact: *mut dc_contact_t) ->
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_contact_was_seen_recently(contact: *mut dc_contact_t) -> libc::c_int {
pub unsafe extern "C" fn dc_contact_get_freshness(contact: *mut dc_contact_t) -> libc::c_int {
if contact.is_null() {
eprintln!("ignoring careless call to dc_contact_was_seen_recently()");
eprintln!("ignoring careless call to dc_contact_get_freshness()");
return 0;
}
let ffi_contact = unsafe { &*contact };
ffi_contact.contact.was_seen_recently() as libc::c_int
u32::from(ffi_contact.contact.get_freshness()) as libc::c_int
}
#[unsafe(no_mangle)]
-12
View File
@@ -47,8 +47,6 @@ impl Lot {
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::AskJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::FprOk { .. } => None,
Qr::FprMismatch { .. } => None,
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
Qr::Account { domain } => Some(Cow::Borrowed(domain)),
Qr::Backup2 { .. } => None,
Qr::BackupTooNew { .. } => None,
@@ -103,8 +101,6 @@ impl Lot {
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
Qr::FprOk { .. } => LotState::QrFprOk,
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
Qr::Account { .. } => LotState::QrAccount,
Qr::Backup2 { .. } => LotState::QrBackup2,
Qr::BackupTooNew { .. } => LotState::QrBackupTooNew,
@@ -132,8 +128,6 @@ impl Lot {
Qr::AskVerifyGroup { .. } => Default::default(),
Qr::AskJoinBroadcast { .. } => Default::default(),
Qr::FprOk { contact_id } => contact_id.to_u32(),
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
Qr::FprWithoutAddr { .. } => Default::default(),
Qr::Account { .. } => Default::default(),
Qr::Backup2 { .. } => Default::default(),
Qr::BackupTooNew { .. } => Default::default(),
@@ -181,12 +175,6 @@ pub enum LotState {
/// id=contact
QrFprOk = 210,
/// id=contact
QrFprMismatch = 220,
/// text1=formatted fingerprint
QrFprWithoutAddr = 230,
/// text1=domain
QrAccount = 250,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc-bindings"
version = "2.61.0-dev"
version = "2.63.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.61.0-dev"
"version": "2.63.0-dev"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.61.0-dev"
version = "2.63.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2024"
license = "MPL-2.0"
+12
View File
@@ -546,6 +546,18 @@ impl CommandApi {
ctx.add_transport_from_qr(&qr).await
}
/// Adds an initial transport on the chatmail relay that answers fastest
/// and lets the profile add further ones in the background.
///
/// A `DCACCOUNT:` or `DCLOGIN:` `qr` code adds a single transport
/// while securejoin codes add the inviter's relays to the candidates.
///
/// Does nothing if the profile already has a transport.
async fn init_transports(&self, account_id: u32, qr: Option<String>) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.init_transports(qr.as_deref()).await
}
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to remove a transport.
@@ -13,7 +13,6 @@ pub enum Account {
Configured {
id: u32,
display_name: Option<String>,
addr: Option<String>,
// size: u32,
profile_image: Option<String>,
color: String,
@@ -29,7 +28,6 @@ impl Account {
pub async fn from_context(ctx: &deltachat::context::Context, id: u32) -> Result<Self> {
if ctx.is_configured().await? {
let display_name = ctx.get_config(Config::Displayname).await?;
let addr = ctx.get_config(Config::Addr).await?;
let profile_image = ctx.get_config(Config::Selfavatar).await?;
let color = color_int_to_hex_string(
Contact::get_by_id(ctx, ContactId::SELF)
@@ -41,7 +39,6 @@ impl Account {
Ok(Account::Configured {
id,
display_name,
addr,
profile_image,
color,
private_tag,
+11 -8
View File
@@ -9,6 +9,8 @@ use deltachat::context::Context;
use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
use crate::api::types::contact::ContactFreshness;
use super::color_int_to_hex_string;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
@@ -69,7 +71,7 @@ pub struct FullChat {
is_muted: bool,
ephemeral_timer: u32,
can_send: bool,
was_seen_recently: bool,
freshness: ContactFreshness,
mailing_list_address: Option<String>,
}
@@ -92,16 +94,17 @@ impl FullChat {
let can_send = chat.can_send(context).await?;
let was_seen_recently = if chat.get_type() == Chattype::Single {
let freshness = if chat.get_type() == Chattype::Single {
match contact_ids.first() {
Some(contact) => Contact::get_by_id(context, *contact)
.await
.context("failed to load contact for was_seen_recently")?
.was_seen_recently(),
None => false,
.context("failed to load contact for get_freshness")?
.get_freshness()
.into(),
None => ContactFreshness::Normal,
}
} else {
false
ContactFreshness::Normal
};
let mailing_list_address = chat.get_mailinglist_addr().map(|s| s.to_string());
@@ -126,7 +129,7 @@ impl FullChat {
is_muted: chat.is_muted(),
ephemeral_timer,
can_send,
was_seen_recently,
freshness,
mailing_list_address,
})
}
@@ -137,7 +140,7 @@ impl FullChat {
/// - fresh_message_counter
/// - ephemeral_timer
/// - self_in_group
/// - was_seen_recently
/// - freshness
/// - can_send
///
/// used when you only need the basic metadata of a chat like type, name, profile picture
+11 -11
View File
@@ -11,6 +11,8 @@ use num_traits::cast::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
use crate::api::types::contact::ContactFreshness;
use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string;
use super::message::MessageViewtype;
@@ -68,7 +70,7 @@ pub enum ChatListItemFetchResult {
is_contact_request: bool,
/// contact id if this is a dm chat (for view profile entry in context menu)
dm_chat_contact: Option<u32>,
was_seen_recently: bool,
freshness: ContactFreshness,
last_message_type: Option<MessageViewtype>,
last_message_id: Option<u32>,
},
@@ -127,22 +129,20 @@ pub(crate) async fn get_chat_list_item_by_id(
None => (None, None),
};
let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single {
let (dm_chat_contact, freshness) = if chat.get_type() == Chattype::Single {
let chat_contacts = get_chat_contacts(ctx, chat_id).await?;
let contact = chat_contacts.first();
let was_seen_recently = match contact {
let freshness = match contact {
Some(contact) => Contact::get_by_id(ctx, *contact)
.await
.context("contact")?
.was_seen_recently(),
None => false,
.get_freshness()
.into(),
None => ContactFreshness::Normal,
};
(
contact.map(|contact_id| contact_id.to_u32()),
was_seen_recently,
)
(contact.map(|contact_id| contact_id.to_u32()), freshness)
} else {
(None, false)
(None, ContactFreshness::Normal)
};
let color = color_int_to_hex_string(chat.get_color(ctx).await?);
@@ -170,7 +170,7 @@ pub(crate) async fn get_chat_list_item_by_id(
is_muted: chat.is_muted(),
is_contact_request: chat.is_contact_request(),
dm_chat_contact,
was_seen_recently,
freshness,
last_message_type: message_type,
last_message_id: last_msgid.map(|id| id.to_u32()),
})
+24 -2
View File
@@ -1,4 +1,5 @@
use anyhow::Result;
use deltachat::contact;
use deltachat::context::Context;
use deltachat::key::{DcKey, SignedPublicKey};
use serde::Serialize;
@@ -6,6 +7,27 @@ use typescript_type_def::TypeDef;
use super::color_int_to_hex_string;
/// Freshness of a contact, based on when it was last seen.
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
pub enum ContactFreshness {
/// Contact shall not be highlighted.
Normal,
/// Contact was seen recently.
RecentlySeen,
/// Contact was not seen for a long time.
Old,
}
impl From<contact::Freshness> for ContactFreshness {
fn from(freshness: contact::Freshness) -> Self {
match freshness {
contact::Freshness::Normal => ContactFreshness::Normal,
contact::Freshness::RecentlySeen => ContactFreshness::RecentlySeen,
contact::Freshness::Old => ContactFreshness::Old,
}
}
}
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "Contact", rename_all = "camelCase")]
pub struct ContactObject {
@@ -32,7 +54,7 @@ pub struct ContactObject {
/// the contact's last seen timestamp
last_seen: i64,
was_seen_recently: bool,
freshness: ContactFreshness,
/// If the contact is a bot.
is_bot: bool,
@@ -60,7 +82,7 @@ impl ContactObject {
is_key_contact: contact.is_key_contact(),
e2ee_avail: contact.e2ee_avail(context).await?,
last_seen: contact.last_seen(),
was_seen_recently: contact.was_seen_recently(),
freshness: contact.get_freshness().into(),
is_bot: contact.is_bot(),
})
}
+12 -4
View File
@@ -44,9 +44,6 @@ pub enum EventType {
/// Emitted when an IMAP message has been marked as deleted
ImapMessageDeleted { msg: String },
/// Emitted when an IMAP message has been moved
ImapMessageMoved { msg: String },
/// Emitted before going into IDLE on the Inbox folder.
ImapInboxIdle,
@@ -261,6 +258,15 @@ pub enum EventType {
chat_id: u32,
},
/// The list of pinned messages for the chat has changed.
///
/// Some message got pinned, or pinned message is unpinned or deleted.
#[serde(rename_all = "camelCase")]
PinnedMessagesChanged {
/// ID of the chat where the list of pinned messages changed.
chat_id: u32,
},
/// Contact(s) created, renamed, blocked or deleted.
#[serde(rename_all = "camelCase")]
ContactsChanged {
@@ -496,7 +502,6 @@ impl From<CoreEventType> for EventType {
CoreEventType::ImapConnected(msg) => ImapConnected { msg },
CoreEventType::SmtpMessageSent(msg) => SmtpMessageSent { msg },
CoreEventType::ImapMessageDeleted(msg) => ImapMessageDeleted { msg },
CoreEventType::ImapMessageMoved(msg) => ImapMessageMoved { msg },
CoreEventType::ImapInboxIdle => ImapInboxIdle,
CoreEventType::NewBlobFile(file) => NewBlobFile { file },
CoreEventType::DeletedBlobFile(file) => DeletedBlobFile { file },
@@ -516,6 +521,9 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
contact_id: contact_id.to_u32(),
},
CoreEventType::PinnedMessagesChanged { chat_id } => PinnedMessagesChanged {
chat_id: chat_id.to_u32(),
},
CoreEventType::IncomingReaction {
chat_id,
contact_id,
-15
View File
@@ -68,16 +68,6 @@ pub enum QrObject {
/// Contact ID.
contact_id: u32,
},
/// Scanned fingerprint does not match the last seen fingerprint.
FprMismatch {
/// Contact ID.
contact_id: Option<u32>,
},
/// The scanned QR code contains a fingerprint but no e-mail address.
FprWithoutAddr {
/// Key fingerprint.
fingerprint: String,
},
/// Ask the user if they want to create an account on the given domain.
Account {
/// Server domain name.
@@ -296,11 +286,6 @@ impl From<Qr> for QrObject {
let contact_id = contact_id.to_u32();
QrObject::FprOk { contact_id }
}
Qr::FprMismatch { contact_id } => {
let contact_id = contact_id.map(|contact_id| contact_id.to_u32());
QrObject::FprMismatch { contact_id }
}
Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint },
Qr::Account { domain } => QrObject::Account { domain },
Qr::Backup2 {
ref node_addr,
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.61.0-dev"
version = "2.63.0-dev"
license = "MPL-2.0"
edition = "2024"
repository = "https://github.com/chatmail/core"
@@ -8,7 +8,7 @@ repository = "https://github.com/chatmail/core"
[dependencies]
anyhow = { workspace = true }
deltachat = { workspace = true, features = ["internals"]}
dirs = "6"
dirs = "7"
log = { workspace = true }
nu-ansi-term = { workspace = true }
qr2term = "0.3.3"
+49 -60
View File
@@ -20,62 +20,13 @@ use deltachat::message::{self, Message, MessageState, MsgId, Viewtype};
use deltachat::mimeparser::SystemMessage;
use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data};
use deltachat::qr::*;
use deltachat::qr_code_generator::create_qr_svg;
use deltachat::qr_code_generator::{create_qr_svg, get_securejoin_qr_svg};
use deltachat::reaction::send_reaction;
use deltachat::receive_imf::*;
use deltachat::sql;
use deltachat::tools::*;
use tokio::fs;
/// Reset database tables.
/// Argument is a bitmask, executing single or multiple actions in one call.
/// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4.
async fn reset_tables(context: &Context, bits: i32) {
println!("Resetting tables ({bits})...");
if 0 != bits & 4 {
context
.sql()
.execute("DELETE FROM keypairs;", ())
.await
.unwrap();
println!("(4) Private keypairs reset.");
}
if 0 != bits & 8 {
context
.sql()
.execute("DELETE FROM contacts WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats_contacts;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM msgs WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute(
"DELETE FROM config WHERE keyname LIKE 'imap.%' OR keyname LIKE 'configured%';",
(),
)
.await
.unwrap();
context.sql().config_cache().write().await.clear();
println!("(8) Rest but server config reset.");
}
context.emit_msgs_changed_without_ids();
}
async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> {
let data = read_file(context, filename).await?;
@@ -304,7 +255,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
export-keys\n\
import-keys <key-file>\n\
poke [<eml-file>|<folder>|<addr> <key-file>]\n\
reset <flags>\n\
stop\n\
============================================="
),
@@ -396,9 +346,57 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
============================================="
),
},
"connect" => {
context.start_io().await;
}
"disconnect" => {
context.stop_io().await;
}
"fetch" => {
context.background_fetch().await?;
}
"configure" => {
context.configure().await?;
}
"has-backup" => {
has_backup(&context, blobdir).await?;
}
"clear" => {
println!("\n\n\n");
print!("\x1b[1;1H\x1b[2J");
}
"getqr" | "getbadqr" => {
context.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let mut qr = deltachat::securejoin::get_securejoin_qr(&context, group).await?;
if !qr.is_empty() {
if arg0 == "getbadqr" && qr.len() > 40 {
qr.replace_range(12..22, "0000000000")
}
println!("{qr}");
qr2term::print_qr(qr.as_str())?;
}
}
"getqrsvg" => {
context.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let file = dirs::home_dir().unwrap_or_default().join("qr.svg");
match get_securejoin_qr_svg(&context, group).await {
Ok(svg) => {
fs::write(&file, svg).await?;
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {err}");
}
}
}
"joinqr" => {
context.start_io().await;
if !arg0.is_empty() {
deltachat::securejoin::join_securejoin(&context, arg1).await?;
}
}
"export-backup" => {
let dir = dirs::home_dir().unwrap_or_default();
imex(
@@ -444,15 +442,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"poke" => {
ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed");
}
"reset" => {
ensure!(
!arg1.is_empty(),
"Argument <bits> missing: 4=private keys, 8=rest but server config"
);
let bits: i32 = arg1.parse()?;
ensure!(bits < 16, "<bits> must be lower than 16.");
reset_tables(&context, bits).await;
}
"stop" => {
context.stop_ongoing().await;
}
+9 -91
View File
@@ -13,8 +13,6 @@ use anyhow::{Error, bail};
use deltachat::EventType;
use deltachat::chat::ChatId;
use deltachat::context::*;
use deltachat::qr_code_generator::get_securejoin_qr_svg;
use deltachat::securejoin::*;
use log::{error, info, warn};
use nu_ansi_term::Color;
use rustyline::completion::{Completer, FilenameCompleter, Pair};
@@ -25,7 +23,6 @@ use rustyline::validate::Validator;
use rustyline::{
Cmd, CompletionType, Config, Context as RustyContext, EditMode, Editor, Helper, KeyEvent,
};
use tokio::fs;
use tokio::runtime::Handle;
use tracing_subscriber::EnvFilter;
@@ -147,7 +144,7 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 10] = [
const IMEX_COMMANDS: [&str; 9] = [
"has-backup",
"export-backup",
"import-backup",
@@ -156,7 +153,6 @@ const IMEX_COMMANDS: [&str; 10] = [
"export-keys",
"import-keys",
"poke",
"reset",
"stop",
];
@@ -353,23 +349,16 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
Ok(line) => {
// TODO: ignore "set mail_pw"
rl.add_history_entry(line.as_str())?;
let should_continue = Handle::current().block_on(async {
match handle_cmd(line.trim(), ctx.clone(), &mut selected_chat).await {
Ok(ExitResult::Continue) => true,
Ok(ExitResult::Exit) => {
println!("Exiting ...");
false
}
Err(err) => {
eprintln!("Error: {err:#}");
true
}
}
});
if !should_continue {
let line = line.trim();
if matches!(line, "exit" | "quit") {
println!("Exiting ...");
break;
}
if let Err(err) =
Handle::current().block_on(cmdline(ctx.clone(), line, &mut selected_chat))
{
eprintln!("Error: {err:#}");
}
}
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
println!("Exiting...");
@@ -393,77 +382,6 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
Ok(())
}
#[derive(Debug)]
enum ExitResult {
Continue,
Exit,
}
async fn handle_cmd(
line: &str,
ctx: Context,
selected_chat: &mut ChatId,
) -> Result<ExitResult, Error> {
let mut args = line.splitn(2, ' ');
let arg0 = args.next().unwrap_or_default();
let arg1 = args.next().unwrap_or_default();
match arg0 {
"connect" => {
ctx.start_io().await;
}
"disconnect" => {
ctx.stop_io().await;
}
"fetch" => {
ctx.background_fetch().await?;
}
"configure" => {
ctx.configure().await?;
}
"clear" => {
println!("\n\n\n");
print!("\x1b[1;1H\x1b[2J");
}
"getqr" | "getbadqr" => {
ctx.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let mut qr = get_securejoin_qr(&ctx, group).await?;
if !qr.is_empty() {
if arg0 == "getbadqr" && qr.len() > 40 {
qr.replace_range(12..22, "0000000000")
}
println!("{qr}");
qr2term::print_qr(qr.as_str())?;
}
}
"getqrsvg" => {
ctx.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let file = dirs::home_dir().unwrap_or_default().join("qr.svg");
match get_securejoin_qr_svg(&ctx, group).await {
Ok(svg) => {
fs::write(&file, svg).await?;
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {err}");
}
}
}
"joinqr" => {
ctx.start_io().await;
if !arg0.is_empty() {
join_securejoin(&ctx, arg1).await?;
}
}
"exit" | "quit" => return Ok(ExitResult::Exit),
_ => cmdline(ctx.clone(), line, selected_chat).await?,
}
Ok(ExitResult::Continue)
}
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing_subscriber::fmt()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.61.0-dev"
version = "2.63.0-dev"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
@@ -139,6 +139,18 @@ class Account:
"""Add a new transport using a QR code."""
yield self._rpc.add_transport_from_qr.future(self.id, qr)
@futuremethod
def init_transports(self, qr: Optional[str] = None):
"""Add an initial transport on the chatmail relay that answers fastest.
The profile then adds further ones in the background.
A ``DCACCOUNT:`` or ``DCLOGIN:`` ``qr`` code adds a single transport
while securejoin codes add the inviter's relays to the candidates.
Does nothing if the profile already has a transport.
"""
yield self._rpc.init_transports.future(self.id, qr)
def delete_transport(self, addr: str):
"""Delete a transport."""
self._rpc.delete_transport(self.id, addr)
@@ -38,7 +38,6 @@ class EventType(str, Enum):
IMAP_CONNECTED = "ImapConnected"
SMTP_MESSAGE_SENT = "SmtpMessageSent"
IMAP_MESSAGE_DELETED = "ImapMessageDeleted"
IMAP_MESSAGE_MOVED = "ImapMessageMoved"
IMAP_INBOX_IDLE = "ImapInboxIdle"
NEW_BLOB_FILE = "NewBlobFile"
DELETED_BLOB_FILE = "DeletedBlobFile"
+10 -2
View File
@@ -24,8 +24,16 @@ class DirectImap:
def __init__(self, account: Account, addr=None, password=None) -> None:
self.account = account
self.addr = addr or account.get_config("addr")
self.password = password or account.get_config("mail_pw")
if addr is None or password is None:
transport = account.list_transports()[-1]
if addr is None:
self.addr = transport["addr"]
else:
self.addr = addr
if password is None:
self.password = transport["password"]
else:
self.password = password
self.logid = account.get_config("displayname") or id(account)
self._idling = False
self.connect()
@@ -24,6 +24,12 @@ def wait_for_imap_message(imap):
time.sleep(1)
def test_init_transports(acf):
account = acf.get_unconfigured_account()
account.init_transports(acf.get_account_qr())
assert len(account.list_transports()) == 1
def test_add_second_address(acf) -> None:
account = acf.new_configured_account()
assert len(account.list_transports()) == 1
@@ -345,12 +351,11 @@ def test_qr_works_after_removing_primary_transport(acf, log) -> None:
log.section("Alice creates a QR code")
chat_qr = alice.get_qr_code()
chat_qr_unquoted = urllib.parse.unquote(chat_qr)
assert f"&a={first_addr}" in chat_qr_unquoted
assert f"&r={third_addr},{second_addr}" in chat_qr_unquoted
assert f"&a={third_addr}" in chat_qr_unquoted
assert f"&r={second_addr},{first_addr}" in chat_qr_unquoted
log.section("Alice removes first and second transport")
alice.set_config("configured_addr", third_addr)
alice.delete_transport(first_addr)
log.section("Alice removes the transport named by the a= parameter")
alice.delete_transport(third_addr)
alice.delete_transport(second_addr)
log.section("Bob scans the QR code, which still works")
+40 -17
View File
@@ -1,4 +1,5 @@
import logging
import time
import pytest
@@ -27,7 +28,7 @@ def test_qr_setup_contact(acf) -> None:
def test_qr_setup_contact_svg(acf) -> None:
alice = acf.new_configured_account()
_, _, domain = alice.get_config("addr").rpartition("@")
_, _, domain = alice.list_transports()[0]["addr"].rpartition("@")
_qr_code, svg = alice.get_qr_code_svg()
@@ -43,6 +44,7 @@ def test_qr_setup_contact_svg(acf) -> None:
def test_qr_securejoin(acf):
alice, bob, fiona = acf.get_online_accounts(3)
alice.set_config("displayname", "Alice")
# Setup second device for Alice
# to test observing securejoin protocol.
alice2 = alice.clone()
@@ -67,7 +69,7 @@ def test_qr_securejoin(acf):
assert alice_contact_bob_snapshot.e2ee_avail
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by {}.".format(alice.get_config("addr"))
assert snapshot.text == "You were added by Alice."
bob_contact_alice = bob.create_contact(alice)
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
@@ -89,6 +91,30 @@ def test_qr_securejoin(acf):
fiona.wait_for_securejoin_joiner_success()
def test_qr_securejoin_request_deleted_on_all_transports(acf):
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
alice_chat = alice.create_group("Group")
qr_code = alice_chat.get_qr_code()
alice2 = alice.clone()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
alice_chat.remove_contact(bob)
# Stop and start io in order to wait until the remove message is sent out.
alice.stop_io()
alice.bring_online()
# Membership timestamps have a resolution of one second.
time.sleep(1)
# By now, the securejoin message sent by Bob must be deleted on Alice's relays.
# Otherwise, alice2 would execute securejoin again and re-add Bob.
alice2.bring_online()
alice2_chat = alice2.get_chat_by_id(alice_chat.id)
assert alice2.create_contact(bob) not in alice2_chat.get_contacts()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_qr_securejoin_broadcast(acf, all_devices_online):
alice, bob, fiona = acf.get_online_accounts(3)
@@ -146,17 +172,13 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
assert "invited you to join this channel" in first_msg.text
assert first_msg.is_info
if inviter_side:
member_added_msg = chat_msgs.pop(0).get_snapshot()
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
assert member_added_msg.info_contact_id == contact_snapshot.id
else:
if not inviter_side:
if chat_msgs[0].get_snapshot().text == "You joined the channel.":
member_added_msg = chat_msgs.pop(0).get_snapshot()
else:
member_added_msg = chat_msgs.pop(1).get_snapshot()
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
assert member_added_msg.is_info
hello_msg = chat_msgs.pop(0).get_snapshot()
assert hello_msg.text == "Hello everyone!"
@@ -221,7 +243,7 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You joined the channel."
get_broadcast(alice2).get_messages()[2].resend()
get_broadcast(alice2).get_messages()[1].resend()
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!"
@@ -503,11 +525,9 @@ def test_aeap_flow(acf):
assert msg_in_1.text == msg_out.text
logging.info("changing email account")
ac1.set_config("addr", addr)
ac1.set_config("mail_pw", password)
ac1.stop_io()
ac1.configure()
ac1.start_io()
old_addr = ac1.list_transports()[0]["addr"]
ac1.add_transport_from_qr(acf.get_account_qr())
ac1.delete_transport(old_addr)
logging.info("sending second message")
msg_out = chat.send_text("changed address").get_snapshot()
@@ -529,6 +549,7 @@ def test_securejoin_after_contact_resetup(acf) -> None:
but different key fingerprint while a securejoin with that contact is still pending.
"""
ac1, ac2, ac3 = acf.get_online_accounts(3)
ac3.set_config("displayname", "ac3")
# ac3 creates a group with ac1.
ac3_chat = ac3.create_group("Group")
@@ -540,7 +561,7 @@ def test_securejoin_after_contact_resetup(acf) -> None:
# ac1 waits for member added message and creates a QR code.
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by {}.".format(ac3.get_config("addr"))
assert snapshot.text == "You were added by ac3."
ac1_qr_code = snapshot.chat.get_qr_code()
# ac2 sets up contact with ac1
@@ -580,6 +601,8 @@ def test_securejoin_after_contact_resetup(acf) -> None:
def test_withdraw_securejoin_qr(acf):
alice, bob = acf.get_online_accounts(2)
alice.set_config("displayname", "Alice")
bob.set_config("displayname", "Bob")
logging.info("Alice creates a group")
alice_chat = alice.create_group("Group")
@@ -592,11 +615,11 @@ def test_withdraw_securejoin_qr(acf):
alice.clear_all_events()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by {}.".format(alice.get_config("addr"))
assert snapshot.text == "You were added by Alice."
bob_chat.leave()
snapshot = alice.get_message_by_id(alice.wait_for_msgs_changed_event().msg_id).get_snapshot()
assert snapshot.text == "Group left by {}.".format(bob.get_config("addr"))
assert snapshot.text == "Group left by Bob."
logging.info("Alice withdraws QR code.")
qr = alice.check_qr(qr_code)
+6 -12
View File
@@ -155,7 +155,7 @@ def test_list_transports(acf) -> None:
def test_account(acf) -> None:
alice, bob = acf.get_online_accounts(2)
bob_addr = bob.get_config("addr")
bob_addr = bob.get_config("configured_addr")
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("Hello!")
@@ -318,7 +318,7 @@ def test_chat(acf) -> None:
def test_contact(acf) -> None:
alice, bob = acf.get_online_accounts(2)
bob_addr = bob.get_config("addr")
bob_addr = bob.get_config("configured_addr")
alice_contact_bob = alice.create_contact(bob, "Bob")
assert alice_contact_bob == alice.get_contact_by_id(alice_contact_bob.id)
@@ -604,7 +604,6 @@ def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None:
(ac1, some1) = acf.get_online_accounts(2)
log.section("create some chat content")
some1_addr = some1.get_config("addr")
chat1 = ac1.create_contact(some1).create_chat()
chat1.send_text("msg1")
assert len(ac1.get_contacts()) == 1
@@ -622,7 +621,6 @@ def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None:
contacts = ac.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.get_snapshot().address == some1_addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 3 + E2EE_INFO_MSGS
@@ -1187,7 +1185,6 @@ def test_leave_broadcast(acf, all_devices_online):
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
chat = get_broadcast(ac)
contact_snapshot = contact.get_snapshot()
chat_msgs = chat.get_messages()
encrypted_msg = chat_msgs.pop(0).get_snapshot()
@@ -1199,14 +1196,11 @@ def test_leave_broadcast(acf, all_devices_online):
assert "invited you to join this channel" in first_msg.text
assert first_msg.is_info
member_added_msg = chat_msgs.pop(0).get_snapshot()
if inviter_side:
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
else:
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
if not inviter_side:
member_added_msg = chat_msgs.pop(0).get_snapshot()
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
leave_msg = chat_msgs.pop(0).get_snapshot()
assert leave_msg.text == "You left the channel."
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.61.0-dev"
version = "2.63.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.61.0-dev"
"version": "2.63.0-dev"
}
+9 -2
View File
@@ -630,9 +630,12 @@ CREATE TABLE broadcast_secrets(
-- Candidate chatmail relays for automatic relay management.
-- Holds the hosts a QR code contributed and the default relays already tried;
-- the default list itself lives in `autorelay.rs` and is not stored here.
CREATE TABLE relay_candidates(
host TEXT PRIMARY KEY NOT NULL,
last_tried INTEGER NOT NULL DEFAULT 0 -- Timestamp of the last connection attempt.
-- Timestamp of the last connection attempt, 0 if the host was never tried.
last_tried INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE TABLE transports (
@@ -680,7 +683,11 @@ CREATE TABLE imap (
transport_id INTEGER NOT NULL, -- ID of the transport in the `transports` table.
rfc724_mid TEXT NOT NULL, -- Message-ID header
folder TEXT NOT NULL, -- IMAP folder
target TEXT NOT NULL, -- Destination folder. Empty string means that the message shall be deleted.
-- Destination folder. Empty string means that the message shall be deleted.
-- Since we don't move messages between IMAP folders anymore,
-- this is always either empty or equal to `folder`.
target TEXT NOT NULL,
uid INTEGER NOT NULL, -- UID
uidvalidity INTEGER NOT NULL,
UNIQUE (transport_id, folder, uid, uidvalidity)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.61.0-dev"
version = "2.63.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 -1
View File
@@ -1 +1 @@
2026-09-11
2026-09-22
+136 -43
View File
@@ -1,29 +1,24 @@
//! # Automatic relay handling (experimental, still in development)
//! # Automatic multi-relay onboarding
//!
//! Chatmail relays create an account on first login,
//! so a profile can add further transports on its own without user interaction.
//! Candidate hosts come from the `relay_candidates` table,
//! which migrations seed with a list of known chatmail relays.
//!
//! Status of implementation:
//! Additions are attempted right before going into IMAP IDLE,
//! i.e. only while connected and with nothing more important to do,
//! and only if a UI opted in via [`Config::Autorelay`].
//! Once a profile has reached `NUM_TRANSPORTS_TARGET` transports,
//! [`Config::AutorelayFinished`] is set and nothing is ever added again,
//! so deleting a transport later does not pull in a replacement.
//! Support for automatically onboarding a profile on transport
//! candidates without the user choosing a relay.
use std::collections::BTreeMap;
use std::pin::Pin;
use anyhow::Result;
use anyhow::{Result, format_err};
use deltachat_contact_tools::addr_normalize;
use rand::distr::{Alphanumeric, SampleString};
use rand::seq::IndexedRandom;
use rand::seq::{IndexedRandom, SliceRandom};
use rusqlite::Transaction;
use tokio::task::JoinSet;
use crate::config::{self, Config};
use crate::configure::{EnteredLoginParam, SILENT_PROGRESS, configure};
use crate::log::{LogExt, warn};
use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam};
use crate::{configure::EnteredLoginParam, context::Context, tools::time};
use crate::net::{connect_tcp, proxy::ProxyConfig};
use crate::{context::Context, tools::time};
/// The target number of transports.
const NUM_TRANSPORTS_TARGET: usize = 3;
@@ -32,6 +27,89 @@ const AUTOMATIC_ADDITION_DEBOUNCE_SECONDS: i64 = 60 * 60; // one hour
/// How long we ignore a relay candidate after failing to connect to it:
const BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY: i64 = 60 * 60 * 24 * 7; // one week
/// Sorted relay list a profile can attempt to onboard on without the user choosing one.
const DEFAULT_RELAY_CANDIDATES: &[&str] = &[
"chat.adminforge.de", // iroh relay 404s
"chat.me.ke",
"chat.nuvon.app",
"chat.tinydispatch.org",
"chat.vim.wtf",
"chatmail.au",
"chatmail.cc",
"chatmail.uk",
"chtml.ca",
"deltachat.me",
"e2e.sus.fr",
"e2ee.wang",
"mailchat.pl",
"nchrcht.la10cy.net",
"nine.testrun.org",
"sweetfern.net",
"tarpit.fun",
];
/// Records the hosts of `addrs` as relay candidates.
pub(crate) async fn add_relay_candidates(context: &Context, addrs: &[String]) -> Result<()> {
context
.sql
.transaction(|tx| hosts_of(addrs).try_for_each(|host| save_relay_candidate(tx, host, 0)))
.await
}
/// Adds a first transport on the relay candidate that answers fastest.
///
/// All candidates are probed at once with a TCP connection to their HTTPS port
/// and configured in the order in which the connections complete,
/// stopping at the first success. Candidates that fail the probe are skipped.
/// Answering TCP fastest is used as a network proximity measure,
/// which keeps latency low for initial onboarding,
/// and it avoids relays that are down or black-holing traffic.
pub(crate) async fn add_transport_from_candidates(
context: &Context,
skip_network: bool,
) -> Result<()> {
let mut candidates = triable_relay_candidates(context, time()).await?;
candidates.shuffle(&mut rand::rng());
let mut probes = JoinSet::new();
let proxy_config = ProxyConfig::load(context).await?;
let load_cache = false;
for host in candidates {
let ctx = context.clone();
let proxy_config = proxy_config.clone();
probes.spawn(async move {
let res = match proxy_config {
_ if skip_network => Ok(()),
Some(proxy) => proxy.connect(&ctx, &host, 443, load_cache).await.map(drop),
None => connect_tcp(&ctx, &host, 443, load_cache).await.map(drop),
};
(host, res)
});
}
let mut last_err = format_err!("No relay candidates");
let mark_as_autorelay = true;
while let Some(res) = probes.join_next().await {
let (host, res) = res?;
if let Err(err) = res {
warn!(context, "Failed to connect to relay {host}: {err:#}.");
last_err = err;
continue;
}
let param = login_param_from_host(&host, mark_as_autorelay);
match configure(context, &param, skip_network).await {
Ok(()) => {
info!(context, "Added a transport on relay {host}.");
return Ok(());
}
Err(err) => {
warn!(context, "Failed to add relay {host}: {err:#}.");
last_err = err;
}
}
}
Err(last_err)
}
pub(crate) fn maybe_add_additional_relays(
context: Context,
) -> Pin<Box<dyn Future<Output = ()> + Send>> {
@@ -87,16 +165,13 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
for _ in 0..NUM_TRANSPORTS_TARGET {
if context.count_transports().await? >= NUM_TRANSPORTS_TARGET {
context
.set_config_internal(Config::AutorelayFinished, config::from_bool(true))
.set_config_internal(Config::AutorelayFinished, Some(config::from_bool(true)))
.await?;
return Ok(relay_added);
}
// First, query all candidates that were not tried since `BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY` seconds.
// Hosts that are already used are excluded.
let candidates = load_relay_candidates(context, now).await?;
let candidates = triable_relay_candidates(context, now).await?;
let Some(host) = candidates.choose(&mut rand::rng()) else {
info!(
context,
@@ -113,14 +188,13 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
context
.sql
.execute(
"UPDATE relay_candidates SET last_tried=? WHERE host=?",
(now, host),
)
.transaction(|tx| save_relay_candidate(tx, host, now))
.await?;
let mark_as_autorelay = true;
let param = login_param_from_host(host, mark_as_autorelay);
let res = crate::configure::configure(context, &param, skip_network).await;
let res = SILENT_PROGRESS
.scope((), configure(context, &param, skip_network))
.await;
if let Err(e) = res {
warn!(
context,
@@ -135,30 +209,49 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
Ok(relay_added)
}
async fn load_relay_candidates(context: &Context, now: i64) -> Result<Vec<String>, anyhow::Error> {
async fn triable_relay_candidates(context: &Context, now: i64) -> Result<Vec<String>> {
let cutoff_timestamp = now.saturating_sub(BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY);
let candidates: Vec<String> = context
let mut last_tried: BTreeMap<String, i64> = context
.sql
.query_map_vec(
// This also selects candidates which have last_tried in the future,
// essentially treating them as never tried,
// so if some timestamp far in the future is accidentally stored,
// we are not stuck never trying the candidate.
// After trying the candidate, last_tried will be corrected to the current time.
"SELECT host FROM relay_candidates WHERE (last_tried<? OR last_tried>?)
AND NOT EXISTS (
SELECT 1
FROM transports
WHERE substr(addr, instr(addr, '@') + 1) = host
)",
(cutoff_timestamp, now),
|row| Ok(row.get::<_, String>(0)?),
)
.query_map_collect("SELECT host, last_tried FROM relay_candidates", (), |row| {
Ok((row.get(0)?, row.get(1)?))
})
.await?;
for host in DEFAULT_RELAY_CANDIDATES {
last_tried.entry(host.to_string()).or_insert(0);
}
let self_addrs = context.get_self_addrs().await?;
let used_hosts: Vec<&str> = hosts_of(&self_addrs).collect();
// We also try candidates which have `last_tried` in the future,
// which on next failure get `last_tried` reset to the current time.
let candidates = last_tried
.into_iter()
.filter(|(host, last_tried)| {
(*last_tried < cutoff_timestamp || *last_tried > now)
&& !used_hosts.contains(&host.as_str())
})
.map(|(host, _)| host)
.collect();
Ok(candidates)
}
/// Returns the host of each address in `addrs`.
fn hosts_of(addrs: &[String]) -> impl Iterator<Item = &str> {
addrs.iter().filter_map(|a| Some(a.rsplit_once('@')?.1))
}
/// Records `host` as a relay candidate, overwriting a stored `last_tried`.
fn save_relay_candidate(tx: &Transaction, host: &str, last_tried: i64) -> Result<()> {
tx.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)
ON CONFLICT(host) DO UPDATE SET last_tried=excluded.last_tried",
(host, last_tried),
)?;
Ok(())
}
pub(crate) fn login_param_from_host(host: &str, mark_as_autorelay: bool) -> EnteredLoginParam {
let rng = &mut rand::rng();
let username = Alphanumeric.sample_string(rng, 9);
+147 -62
View File
@@ -1,42 +1,150 @@
use std::time::Duration;
use super::*;
use crate::test_utils::TestContext;
use crate::EventType;
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
/// Tests that the default relays are candidates without a row in the table.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_relay_candidates_single() -> Result<()> {
async fn test_triable_relay_candidates_defaults() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
let now = time();
assert!(DEFAULT_RELAY_CANDIDATES.is_sorted());
let mut candidates = triable_relay_candidates(t, now).await?;
candidates.sort();
assert_eq!(candidates, DEFAULT_RELAY_CANDIDATES);
let tried = DEFAULT_RELAY_CANDIDATES[0];
save_relay_candidates(t, &[tried], now).await?;
let candidates = triable_relay_candidates(t, now).await?;
assert_eq!(candidates.len(), DEFAULT_RELAY_CANDIDATES.len() - 1);
assert!(!candidates.contains(&tried.to_string()));
Ok(())
}
/// Tests that a transport is added on a candidate from the given addresses.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_add_transport_from_candidates() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
mark_defaults_tried(t, time()).await?;
let addrs_from_qr = [
"alice@example.org".to_string(),
"bob@example.org".to_string(),
];
let skip_network = true;
add_relay_candidates(t, &addrs_from_qr).await?;
add_transport_from_candidates(t, skip_network).await?;
let transports = t.list_transports().await?;
assert_eq!(transports.len(), 1);
assert!(transports[0].addr.ends_with("@example.org"));
let untried = untried_relay_candidates(t).await?;
assert_eq!(untried, ["example.org"]);
assert!(configure_progress_emitted(t).await);
Ok(())
}
/// Tests correct add_transport_from_candidates error handling.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_add_transport_from_candidates_failure() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
mark_defaults_tried(t, time()).await?;
save_relay_candidates(t, &["bad host", "worse host"], 0).await?;
let skip_network = true;
let err = add_transport_from_candidates(t, skip_network)
.await
.unwrap_err();
assert!(format!("{err:#}").contains("Bad email-address"));
assert!(!t.is_configured().await?);
let untried = untried_relay_candidates(t).await?;
assert_eq!(untried, ["bad host", "worse host"]);
t.assert_warns_or_errors(&[
"Failed to add relay bad host",
"Failed to add relay worse host",
])
.await;
Ok(())
}
async fn untried_relay_candidates(t: &TestContext) -> Result<Vec<String>> {
t.sql
.query_map_vec(
"SELECT host FROM relay_candidates WHERE last_tried=0 ORDER BY host",
(),
|row| Ok(row.get(0)?),
)
.await
}
async fn save_relay_candidates(t: &TestContext, hosts: &[&str], last_tried: i64) -> Result<()> {
t.sql
.transaction(|tx| {
for host in hosts {
save_relay_candidate(tx, host, last_tried)?;
}
Ok(())
})
.await
}
/// Keeps the default relays out of `triable_relay_candidates()`.
async fn mark_defaults_tried(t: &TestContext, now: i64) -> Result<()> {
save_relay_candidates(t, DEFAULT_RELAY_CANDIDATES, now).await
}
/// Consumes emitted events, telling whether a configure progress is among them.
async fn configure_progress_emitted(t: &TestContext) -> bool {
t.evtracker
.get_matching_opt(t, |evt| matches!(evt, EventType::ConfigureProgress { .. }))
.await
.is_some()
}
/// Tests that saving a candidate overwrites its stored timestamp.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_relay_candidate() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
let now = time();
for last_tried in [0, now, 0] {
t.sql
.transaction(|tx| save_relay_candidate(tx, "relay.example", last_tried))
.await?;
let stored: Option<i64> = t
.sql
.query_get_value(
"SELECT last_tried FROM relay_candidates WHERE host=?",
("relay.example",),
)
.await?;
assert_eq!(stored, Some(last_tried));
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_triable_relay_candidates_single() -> Result<()> {
let t = &TestContext::new_alice().await;
enable_config(t).await;
let now = time();
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
mark_defaults_tried(t, now).await?;
// This host should be returned by load_relay_candidates():
t.sql
.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
("never_tried.example", 0),
)
.await?;
save_relay_candidates(t, &["never_tried.example", "example.org"], 0).await?;
save_relay_candidates(t, &["recent.example"], now).await?;
// This host was recently tried and should not be returned:
t.sql
.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
("recent.example", now),
)
.await?;
// This host is already in use (alice@example.org) and should not be returned:
t.sql
.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
("example.org", 0),
)
.await?;
let candidates = load_relay_candidates(t, now).await?;
let candidates = triable_relay_candidates(t, now).await?;
assert_eq!(candidates, vec!["never_tried.example".to_string()]);
@@ -44,22 +152,15 @@ async fn test_load_relay_candidates_single() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_relay_candidates_multiple() -> Result<()> {
async fn test_triable_relay_candidates_multiple() -> Result<()> {
let t = &TestContext::new().await;
enable_config(t).await;
let now = time();
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
for host in ["a.example", "b.example", "c.example"] {
t.sql
.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
(host, 0),
)
.await?;
}
mark_defaults_tried(t, now).await?;
save_relay_candidates(t, &["a.example", "b.example", "c.example"], 0).await?;
let mut candidates = load_relay_candidates(t, now).await?;
let mut candidates = triable_relay_candidates(t, now).await?;
candidates.sort();
assert_eq!(
@@ -160,13 +261,8 @@ async fn test_maybe_add_additional_relays_add_one() -> Result<()> {
enable_config(t).await;
let now = time();
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
t.sql
.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
("relay.example", 0),
)
.await?;
mark_defaults_tried(t, now).await?;
save_relay_candidates(t, &["relay.example"], 0).await?;
let transports_before = t.count_transports().await?;
@@ -179,6 +275,7 @@ async fn test_maybe_add_additional_relays_add_one() -> Result<()> {
let transports_after = t.count_transports().await?;
assert_eq!(transports_after, transports_before + 1);
assert!(!configure_progress_emitted(t).await);
Ok(())
}
@@ -189,15 +286,8 @@ async fn test_maybe_add_additional_relays_add_multiple() -> Result<()> {
enable_config(t).await;
let now = time();
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
for host in ["a.example", "b.example", "c.example", "d.example"] {
t.sql
.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
(host, 0),
)
.await?;
}
mark_defaults_tried(t, now).await?;
save_relay_candidates(t, &["a.example", "b.example", "c.example", "d.example"], 0).await?;
let skip_network = true;
let relay_added = maybe_add_additional_relays_inner(t, skip_network).await?;
@@ -218,14 +308,9 @@ async fn test_maybe_add_additional_relays_failure() -> Result<()> {
enable_config(t).await;
let now = time();
t.sql.execute("DELETE FROM relay_candidates", ()).await?;
mark_defaults_tried(t, now).await?;
for i in 1..10 {
t.sql
.execute(
"INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)",
(format!("{i}.invalid.example"), 0),
)
.await?;
save_relay_candidates(t, &[format!("{i}.invalid.example").as_str()], 0).await?;
}
let transports_before = t.count_transports().await?;
@@ -254,7 +339,7 @@ async fn test_maybe_add_additional_relays_failure() -> Result<()> {
// ...but not all, because there might be many relay candidates
// and we don't want to try all of them in a single call:
assert_eq!(load_relay_candidates(t, now).await?.is_empty(), false);
assert_eq!(triable_relay_candidates(t, now).await?.is_empty(), false);
t.assert_warns_or_errors(&[
"DNS lookup with memory cache failure",
+26 -31
View File
@@ -164,9 +164,9 @@ impl<'a> BlobObject<'a> {
/// you want to create a [BlobObject] for a filename read from the
/// database.
pub fn from_name(context: &'a Context, name: &str) -> Result<BlobObject<'a>> {
let name = match name.starts_with("$BLOBDIR/") {
true => name.splitn(2, '/').last().unwrap(),
false => name,
let name = match name.strip_prefix("$BLOBDIR/") {
Some(name) => name,
None => name,
};
if !BlobObject::is_acceptible_blob_name(name) {
return Err(format_err!("not an acceptable blob name: {name}"));
@@ -289,6 +289,7 @@ impl<'a> BlobObject<'a> {
name: Option<String>,
viewtype: &mut Viewtype,
) -> Result<String> {
debug_assert!(matches!(viewtype, Viewtype::File | Viewtype::Image));
let (max_wh, max_bytes) =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
@@ -326,6 +327,7 @@ impl<'a> BlobObject<'a> {
max_bytes: usize,
is_avatar: bool,
) -> Result<String> {
debug_assert!(matches!(viewtype, Viewtype::File | Viewtype::Image));
// Add white background only to avatars to spare the CPU.
let mut add_white_bg = is_avatar;
let mut no_exif = false;
@@ -340,45 +342,38 @@ impl<'a> BlobObject<'a> {
// It's strange that BufReader modifies a file position while it takes a non-mut
// reference. Ok, just rewind it.
file.rewind()?;
let imgreader = ImageReader::new(std::io::BufReader::new(&file)).with_guessed_format();
let imgreader = match imgreader {
Ok(ir) => ir,
_ => {
file.rewind()?;
ImageReader::with_format(
std::io::BufReader::new(&file),
ImageFormat::from_path(self.to_abs_path())?,
)
}
};
let imgreader = ImageReader::new(std::io::BufReader::new(&file))
.with_guessed_format()
.context("Failed to guess image format due to I/O error")?;
let fmt = imgreader.format().context("Unknown format")?;
if *vt == Viewtype::File {
*vt = Viewtype::Image;
return Ok(name);
}
let mut img = imgreader.decode().context("image decode failure")?;
let mut img = match fmt {
image::ImageFormat::WebP => {
// `with_guessed_format()` restores file position,
// so `buf_reader` is at the beginning of the file.
let buf_reader = imgreader.into_inner();
let webp_decoder = image::codecs::webp::WebPDecoder::new(buf_reader)
.context("Failed to create WebP decoder")?;
// If WebP has animation, do not try to recode it.
// Recoding into JPEG will result in losing the animation.
if !is_avatar && webp_decoder.has_animation() {
return Ok(name);
}
DynamicImage::from_decoder(webp_decoder)?
}
_ => imgreader.decode().context("Failed to decode image")?,
};
let orientation = exif
.as_ref()
.map(|exif| exif_orientation(exif, context))
.unwrap_or(Orientation::NoTransforms);
let mut encoded = Vec::new();
if *vt == Viewtype::Sticker {
let x_max = img.width().saturating_sub(1);
let y_max = img.height().saturating_sub(1);
if !img.in_bounds(x_max, y_max)
|| !(img.get_pixel(0, 0).0[3] == 0
|| img.get_pixel(x_max, 0).0[3] == 0
|| img.get_pixel(0, y_max).0[3] == 0
|| img.get_pixel(x_max, y_max).0[3] == 0)
{
*vt = Viewtype::Image;
} else {
// Core doesn't auto-assign `Viewtype::Sticker` to messages and stickers coming
// from UIs shouldn't contain sensitive Exif info.
return Ok(name);
}
}
img.apply_orientation(orientation);
// max_wh is the maximum image width and height, i.e. the resolution-limit,
+63 -1
View File
@@ -4,7 +4,9 @@ use super::*;
use crate::message::{Message, Viewtype};
use crate::param::Param;
use crate::sql;
use crate::test_utils::{self, AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext};
use crate::test_utils::{
self, AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
};
use crate::tools::SystemTime;
fn check_image_size(path: impl AsRef<Path>, width: u32, height: u32) -> image::DynamicImage {
@@ -738,6 +740,66 @@ async fn test_send_gif_as_sticker() -> Result<()> {
Ok(())
}
/// Tests that animated WebP is sent without reencoding.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_animated_webp_as_image() -> Result<()> {
let bytes = include_bytes!("../../test-data/image/animated.webp");
let (width, height) = (1280u32, 531u32);
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
alice
.set_config(
Config::MediaQuality,
Some(&(MediaQuality::Worse as i32).to_string()),
)
.await?;
let file = alice.get_blobdir().join("file").with_extension("gif");
fs::write(&file, &bytes)
.await
.context("Failed to write file")?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_and_deduplicate(alice, &file, Some("file.webp"), None)?;
let chat = alice.create_chat(bob).await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let bob_msg = bob.recv_msg(&sent).await;
assert_eq!(bob_msg.get_viewtype(), Viewtype::Image);
assert_eq!(bob_msg.get_width() as u32, width);
assert_eq!(bob_msg.get_height() as u32, height);
assert_eq!(
bob_msg.get_filebytes(bob).await?.unwrap(),
bytes.len() as u64
);
Ok(())
}
/// Tests that if user sets animated WebP as an avatar, it may be recoded.
///
/// We don't want to recode animated WebPs into JPEG and lose animation,
/// but for avatars we don't want animation and transparency anyway.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_animated_webp_avatar() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let avatar_src = t.dir.path().join("avatar.webp");
let avatar_bytes = include_bytes!("../../test-data/image/animated.webp");
fs::write(&avatar_src, avatar_bytes).await.unwrap();
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await?;
let avatar_blob = t.get_config(Config::Selfavatar).await?.unwrap();
assert!(avatar_blob.ends_with(".jpg"));
let scaled_avatar_size = fs::metadata(&avatar_blob).await.unwrap().len();
assert!(
scaled_avatar_size < avatar_bytes.len() as u64,
"Animated WebP avatar must be recoded"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_and_deduplicate() -> Result<()> {
let t = TestContext::new().await;
+26 -43
View File
@@ -2895,11 +2895,11 @@ 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 {
// FIXME: remove this message? We don't know yet which provider we are going to send the message through.
let addr = context.get_primary_self_addr().await?;
let addr = context.get_config(Config::ConfiguredAddr).await?;
let text = stock_str::unencrypted_email(
context,
addr.split('@')
addr.unwrap_or_default()
.split('@')
.nth(1)
.unwrap_or_default(),
)
@@ -2924,9 +2924,7 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
);
}
if let Some(ref side_effects) = side_effects {
msg.subject.clone_from(&side_effects.subject);
}
msg.subject.clone_from(&side_effects.subject);
if is_encrypted {
msg.param.set_int(Param::GuaranteeE2ee, 1);
} else {
@@ -2968,13 +2966,13 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
now,
msg.id,
&queued_pre_msg,
pre_side_effects.as_ref(),
Some(&pre_side_effects),
)
.context("Failed to enqueue pre-message")?;
row_ids.push(row_id)
}
row_ids.push(
enqueue_mail(transaction, now, msg.id, &queued_msg, side_effects.as_ref())
enqueue_mail(transaction, now, msg.id, &queued_msg, Some(&side_effects))
.context("Failed to enqueue message")?,
);
Ok(row_ids)
@@ -3639,7 +3637,7 @@ pub(crate) async fn create_out_broadcast_ext(
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
let mut params: Params = Params::new();
params.update_timestamp(Param::GroupNameTimestamp, time())?;
params.update_timestamp(Param::GroupNameTimestamp, time());
t.execute(
"INSERT INTO chats
@@ -3918,15 +3916,7 @@ pub(crate) async fn add_contact_to_chat_ext(
msg.viewtype = Viewtype::Text;
let contact_addr = contact.get_addr().to_lowercase();
let added_by = if from_handshake && chat.typ == Chattype::OutBroadcast {
// The contact was added via a QR code rather than explicit user action,
// so it could be confusing to say 'You added member Alice'.
// And in a broadcast, SELF is the only one who can add members,
// so, no information is lost by just writing 'Member Alice added' instead.
ContactId::UNDEFINED
} else {
ContactId::SELF
};
let added_by = ContactId::SELF;
msg.text = stock_str::msg_add_member_local(context, contact.id, added_by).await;
msg.param.set_cmd(SystemMessage::MemberAddedToGroup);
msg.param.set(Param::Arg, contact_addr);
@@ -3940,6 +3930,11 @@ pub(crate) async fn add_contact_to_chat_ext(
.await?
.context("Failed to find broadcast shared secret")?;
msg.param.set(PARAM_BROADCAST_SECRET, secret);
// We don't show "member added" info-messages in channels,
// because there can be a lot members added,
// and these messages would clutter the timeline.
msg.hidden = true;
}
send_msg(context, chat_id, &mut msg).await?;
@@ -4798,12 +4793,14 @@ pub(crate) async fn get_chat_id_by_grpid(
///
/// Optional `label` can be provided to ensure that message is added only once.
/// If `important` is true, a notification will be sent.
/// `timestamp_sent` is the time shown on the message; it does not affect ordering.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_device_msg_with_importance(
context: &Context,
label: Option<&str>,
msg: Option<&mut Message>,
important: bool,
timestamp_sent: i64,
) -> Result<MsgId> {
ensure!(
label.is_some() || msg.is_some(),
@@ -4823,11 +4820,10 @@ pub async fn add_device_msg_with_importance(
chat_id = ChatId::get_for_contact(context, ContactId::DEVICE).await?;
let rfc724_mid = create_outgoing_rfc724_mid();
let timestamp_sent = time();
// makes sure, the added message is the last one,
// even if the date is wrong (useful esp. when warning about bad dates)
msg.timestamp_sort = timestamp_sent;
msg.timestamp_sort = time();
if let Some(last_msg_time) = chat_id.get_timestamp(context).await?
&& msg.timestamp_sort <= last_msg_time
{
@@ -4895,7 +4891,7 @@ pub async fn add_device_msg(
label: Option<&str>,
msg: Option<&mut Message>,
) -> Result<MsgId> {
add_device_msg_with_importance(context, label, msg, false).await
add_device_msg_with_importance(context, label, msg, false, time()).await
}
/// Returns true if device message with a given label was ever added to the device chat.
@@ -4923,6 +4919,12 @@ pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Resul
.execute("DELETE FROM msgs WHERE from_id=?;", (ContactId::DEVICE,))
.await?;
context.sql.execute("DELETE FROM devmsglabels;", ()).await?;
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
// Insert labels for welcome messages to avoid them being re-added on reconfiguration.
context
@@ -5107,7 +5109,7 @@ async fn set_contacts_by_fingerprints(
if contacts == contacts_old {
return Ok(());
}
let broadcast_contacts_added = context
context
.sql
.transaction(move |transaction| {
// For broadcast channels, we only add members,
@@ -5124,31 +5126,12 @@ async fn set_contacts_by_fingerprints(
let mut statement = transaction.prepare(
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
)?;
let mut broadcast_contacts_added = Vec::new();
for contact_id in &contacts {
if statement.execute((id, contact_id))? > 0 && chat.typ == Chattype::OutBroadcast {
broadcast_contacts_added.push(*contact_id);
}
statement.execute((id, contact_id))?;
}
Ok(broadcast_contacts_added)
Ok(())
})
.await?;
let timestamp = time();
for added_id in broadcast_contacts_added {
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
add_info_msg_with_cmd(
context,
id,
&msg,
SystemMessage::MemberAddedToGroup,
Some(timestamp),
timestamp,
None,
Some(ContactId::SELF),
Some(added_id),
)
.await?;
}
context.emit_event(EventType::ChatModified(id));
Ok(())
}
+15 -29
View File
@@ -3003,13 +3003,13 @@ async fn test_broadcast_change_name() -> Result<()> {
tcm.section("Bob receives the name-change system message");
let msg = bob.recv_msg(&sent).await;
assert_eq!(msg.subject, "Re: My great broadcast");
assert_eq!(msg.subject, "My great broadcast");
let bob_chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(bob_chat.name, "My great broadcast");
tcm.section("Fiona receives the name-change system message");
let msg = fiona.recv_msg(&sent).await;
assert_eq!(msg.subject, "Re: My great broadcast");
assert_eq!(msg.subject, "My great broadcast");
let fiona_chat = Chat::load_from_db(fiona, msg.chat_id).await?;
assert_eq!(fiona_chat.name, "My great broadcast");
}
@@ -3343,41 +3343,27 @@ async fn test_broadcast_recipients_sync1() -> Result<()> {
alice2.assert_warn("unknown grpid").await;
let member_added = alice1.pop_sent_msg().await;
let a2_charlie_added = alice2.recv_msg(&member_added).await;
alice2.recv_msg_trash(&member_added).await;
let _c_member_added = charlie.recv_msg(&member_added).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_charlie_added.id);
// Alice1 will now sync the full member list to Alice2:
sync(alice1, alice2).await;
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
let msg_id = a2_chatlist.get_msg_id(0)?.unwrap();
let a2_bob_added = Message::load_from_db(alice2, msg_id).await?;
assert_ne!(a2_bob_added.id, a2_charlie_added.id);
assert_eq!(
a2_bob_added.text,
stock_str::msg_add_member_local(alice2, a2_bob_contact, ContactId::UNDEFINED).await
);
assert_eq!(a2_bob_added.from_id, ContactId::SELF);
assert_eq!(
a2_bob_added.param.get_cmd(),
SystemMessage::MemberAddedToGroup
);
assert_eq!(
ContactId::new(
a2_bob_added
.param
.get_int(Param::ContactAddedRemoved)
.unwrap()
.try_into()
.unwrap()
),
a2_bob_contact
);
let a2_chat_id = a2_chatlist.get_chat_id(0).unwrap();
let a2_chat_members = get_chat_contacts(alice2, a2_charlie_added.chat_id).await?;
// Also for Alice2, no info message should be shown;
// she should see only the "Messages are end-to-end encrypted" message.
let a2_chat_msgs = get_chat_msgs(alice2, a2_chat_id).await?;
assert_eq!(a2_chat_msgs.len(), 1);
let ChatItem::Message { msg_id } = a2_chat_msgs[0] else {
unreachable!()
};
let a2_msg = Message::load_from_db(alice2, msg_id).await?;
assert_eq!(a2_msg.get_info_type(), SystemMessage::ChatE2ee);
let a2_chat_members = get_chat_contacts(alice2, a2_chat_id).await?;
assert!(a2_chat_members.contains(&a2_bob_contact));
assert!(a2_chat_members.contains(&a2_charlie_contact));
assert_eq!(a2_chat_members.len(), 2);
+15 -12
View File
@@ -336,6 +336,17 @@ pub enum Config {
/// Timestamp of the last time housekeeping was run
LastHousekeeping,
/// ID of the device message added after backup transfer.
///
/// If the message does not exist on the first housekeeping
/// after `READD_BACKUP_TRANSFER_MSG_DELAY`, it is re-added.
BackupTransferMsgId,
/// Timestamp of the device message added after backup transfer.
///
/// Needed to check against `READD_BACKUP_TRANSFER_MSG_DELAY`.
BackupTransferTimestamp,
/// Timestamp of the last time accumulated broadcast channel reactions were sent
LastReactionsBroadcast,
@@ -526,14 +537,6 @@ impl Context {
/// Get a config key value if set, or a default value. Returns `None` if no value exists.
pub async fn get_config(&self, key: Config) -> Result<Option<String>> {
if key == Config::ConfiguredAddr {
let addr: Option<String> = self
.sql
.query_get_value("SELECT addr FROM transports", ())
.await?;
return Ok(addr);
}
let value = self.get_config_opt(key).await?;
if value.is_some() {
return Ok(value);
@@ -817,7 +820,7 @@ impl Context {
/// Set the given config to a boolean value.
pub async fn set_config_bool(&self, key: Config, value: bool) -> Result<()> {
self.set_config(key, from_bool(value)).await?;
self.set_config(key, Some(from_bool(value))).await?;
Ok(())
}
@@ -838,8 +841,8 @@ impl Context {
}
/// Returns a value for use in `Context::set_config_*()` for the given `bool`.
pub(crate) fn from_bool(val: bool) -> Option<&'static str> {
Some(if val { "1" } else { "0" })
pub(crate) fn from_bool(val: bool) -> &'static str {
if val { "1" } else { "0" }
}
pub(crate) fn bool_from_config(config: Option<&str>) -> bool {
@@ -892,7 +895,7 @@ impl Context {
.await
}
/// Returns address of some transport.
/// Returns the address of the transport used for sending.
/// Returns an error if no self addr is configured.
pub async fn get_primary_self_addr(&self) -> Result<String> {
self.get_config(Config::ConfiguredAddr)
+126 -39
View File
@@ -31,36 +31,39 @@ use crate::login_param::EnteredCertificateChecks;
pub use crate::login_param::EnteredLoginParam;
use crate::net::proxy::ProxyConfig;
use crate::provider::{self, Protocol, Socket};
use crate::qr::{login_param_from_account_qr, login_param_from_login_qr};
use crate::qr::{Qr, check_qr, login_param_from_account_qr, login_param_from_login_qr};
use crate::smtp::Smtp;
use crate::sync::Sync::Nosync;
use crate::tools::time;
use crate::transport::{
ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam,
ConnectionCandidate, delete_transport_row, purge_transport_caches, send_sync_transports,
transport_addrs,
ConnectionCandidate, delete_transport_row, maybe_update_sending_transport,
purge_transport_caches, send_sync_transports, transport_addrs,
};
use crate::{EventType, stock_str};
use crate::{EventType, autorelay, stock_str};
/// Maximum number of relays.
///
/// See <https://github.com/chatmail/core/issues/7608>.
pub(crate) const MAX_RELAYS: usize = 5;
macro_rules! progress {
($context:tt, $progress:expr, $comment:expr) => {
assert!(
$progress <= 1000,
"value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success"
);
$context.emit_event($crate::events::EventType::ConfigureProgress {
progress: $progress,
comment: $comment,
});
};
($context:tt, $progress:expr) => {
progress!($context, $progress, None);
};
tokio::task_local! {
pub(crate) static SILENT_PROGRESS: ();
}
#[track_caller]
fn emit_progress(ctx: &Context, progress: u16) {
assert!(
progress <= 1000,
"value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success"
);
if SILENT_PROGRESS.try_with(|_| ()).is_ok() {
return;
}
ctx.emit_event(EventType::ConfigureProgress {
progress,
comment: None,
});
}
impl Context {
@@ -124,14 +127,17 @@ impl Context {
pub(crate) async fn add_transport_inner(&self, param: &mut EnteredLoginParam) -> Result<()> {
match self.add_transport_unreported(param).await {
Ok(()) => {
progress!(self, 1000);
emit_progress(self, 1000);
Ok(())
}
Err(err) => {
// We are using Anyhow's .context() and to show the
// inner error, too, we need the {:#}:
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
progress!(self, 0, Some(error_msg.clone()));
self.emit_event(EventType::ConfigureProgress {
progress: 0,
comment: Some(error_msg.clone()),
});
bail!(error_msg);
}
}
@@ -156,9 +162,7 @@ impl Context {
.await;
self.free_ongoing().await;
res?;
param.save_legacy(self).await
res
}
/// Adds a new email account as a transport
@@ -190,6 +194,62 @@ impl Context {
Ok(())
}
/// Adds an initial transport on the chatmail relay that answers fastest
/// and lets the profile add further ones in the background.
///
/// A `DCACCOUNT:` or `DCLOGIN:` `qr` code adds a single transport
/// while securejoin codes add the inviter's relays to the candidates.
///
/// Does nothing if the profile already has a transport.
pub async fn init_transports(&self, qr: Option<&str>) -> Result<()> {
if self.is_configured().await? {
return Ok(());
}
if let Some(qr) = qr {
match check_qr(self, qr).await? {
Qr::Account { .. } | Qr::Login { .. } => {
return self.add_transport_from_qr(qr).await;
}
Qr::AskVerifyContact { addrs, .. }
| Qr::AskVerifyGroup { addrs, .. }
| Qr::AskJoinBroadcast { addrs, .. } => {
autorelay::add_relay_candidates(self, &addrs).await?
}
_ => bail!("QR code does not contain a relay"),
}
}
let cancel_channel = self.alloc_ongoing().await?;
let skip_network = false;
let res = autorelay::add_transport_from_candidates(self, skip_network)
.race(cancel_channel.recv().map(|_| Err(format_err!("Canceled"))))
.await;
self.free_ongoing().await;
let configured = self.is_configured().await?;
match res {
Ok(()) => {}
Err(err) if configured => {
warn!(
self,
"Onboarding interrupted after adding a transport: {err:#}."
);
}
Err(err) => {
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
self.emit_event(EventType::ConfigureProgress {
progress: 0,
comment: Some(error_msg.clone()),
});
bail!(error_msg);
}
}
self.set_config_bool(Config::Autorelay, true).await?;
emit_progress(self, 1000);
self.start_io().await;
Ok(())
}
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
@@ -215,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 = self
let (removed_transport_id, reelected) = self
.sql
.transaction(|transaction| {
if transport_addrs(transaction)?.len() <= 1 {
@@ -234,9 +294,14 @@ impl Context {
let remove_timestamp = std::cmp::max(now, add_timestamp);
let transport_id = delete_transport_row(transaction, addr, remove_timestamp)?
.context("Transport disappeared")?;
Ok(transport_id)
let reelected = maybe_update_sending_transport(transaction)?;
Ok((transport_id, reelected))
})
.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.
@@ -316,7 +381,7 @@ async fn get_configured_param(
let parsed = EmailAddress::new(&param.addr).context("Bad email-address")?;
let param_domain = parsed.domain;
progress!(ctx, 200);
emit_progress(ctx, 200);
let param_autoconfig = if param.imap.server.is_empty()
&& param.imap.port == 0
@@ -338,7 +403,7 @@ async fn get_configured_param(
None
};
progress!(ctx, 500);
emit_progress(ctx, 500);
let mut servers = param_autoconfig.unwrap_or_default();
if !servers
@@ -432,13 +497,13 @@ pub(crate) async fn configure(
param: &EnteredLoginParam,
skip_network: bool,
) -> Result<()> {
progress!(ctx, 1);
emit_progress(ctx, 1);
let configured_param = get_configured_param(ctx, param, skip_network).await?;
let proxy_config = ProxyConfig::load(ctx).await?;
let strict_tls = configured_param.strict_tls(proxy_config.is_some())?;
progress!(ctx, 550);
emit_progress(ctx, 550);
if !skip_network {
// Spawn SMTP configuration task
@@ -464,7 +529,7 @@ pub(crate) async fn configure(
Ok::<(), anyhow::Error>(())
});
progress!(ctx, 600);
emit_progress(ctx, 600);
// Configure IMAP
@@ -478,12 +543,12 @@ pub(crate) async fn configure(
}
};
progress!(ctx, 850);
emit_progress(ctx, 850);
// Wait for SMTP configuration
smtp_config_task.await??;
progress!(ctx, 900);
emit_progress(ctx, 900);
// Drop the imap connection explicitly
// to make sure that it's not forgotten in a future refactoring
@@ -491,7 +556,7 @@ pub(crate) async fn configure(
drop(imap);
}
progress!(ctx, 910);
emit_progress(ctx, 910);
configured_param
.clone()
@@ -502,11 +567,11 @@ pub(crate) async fn configure(
ctx.set_config_internal(Config::ConfiguredTimestamp, Some(&time().to_string()))
.await?;
progress!(ctx, 920);
emit_progress(ctx, 920);
ctx.scheduler.interrupt_inbox().await;
progress!(ctx, 940);
emit_progress(ctx, 940);
ctx.update_device_chats()
.await
.context("Failed to update device chats")?;
@@ -550,7 +615,7 @@ async fn get_autoconfig(
{
return Some(res);
}
progress!(ctx, 300);
emit_progress(ctx, 300);
// `?emailaddress=` query string is excluded on purpose.
// It is not part of the URL according to <https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/06/>.
@@ -565,7 +630,7 @@ async fn get_autoconfig(
{
return Some(res);
}
progress!(ctx, 310);
emit_progress(ctx, 310);
// Outlook uses always SSL but different domains (this comment describes the next two steps)
if let Ok(res) = outlk_autodiscover(
@@ -577,7 +642,7 @@ async fn get_autoconfig(
{
return Some(res);
}
progress!(ctx, 320);
emit_progress(ctx, 320);
if let Ok(res) = outlk_autodiscover(
ctx,
@@ -588,7 +653,7 @@ async fn get_autoconfig(
{
return Some(res);
}
progress!(ctx, 330);
emit_progress(ctx, 330);
// always SSL for Thunderbird's database
if let Ok(res) = moz_autoconfigure(
@@ -685,6 +750,28 @@ mod tests {
Ok(())
}
/// Tests that init_transports() fails on a bad code
/// and does nothing on a profile that already has a transport.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_init_transports() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
assert!(t.init_transports(Some("not a qr code")).await.is_err());
assert!(!t.is_configured().await?);
let alice = &tcm.alice().await;
let invite = "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40invite.example&i=TbnwJ6lSvD5&s=0ejvbdFSQxB";
alice.init_transports(Some(invite)).await?;
let candidates = alice
.sql
.count("SELECT COUNT(*) FROM relay_candidates", ())
.await?;
assert_eq!(candidates, 0);
assert_eq!(alice.count_transports().await?, 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_configured_param() -> Result<()> {
let t = &TestContext::new().await;
+1 -1
View File
@@ -29,7 +29,7 @@ impl ServerParams {
if self.username.is_empty() {
vec![Self {
username: addr.to_string(),
..self.clone()
..self
}]
} else {
vec![self]
+56 -11
View File
@@ -40,9 +40,33 @@ use crate::sync::{self, Sync::*};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
use crate::{chat, chatlist_events, ensure_and_debug_assert, stock_str};
/// Time during which a contact is considered as seen recently.
/// If the contact's "last seen" is newer, the contact freshness is set to "recently seen".
const SEEN_RECENTLY_SECONDS: i64 = 600;
/// If the contact's "last seen" is older, the contact freshness is set to "old".
const CONTACT_OLD_SECONDS: i64 = 60 * 24 * 60 * 60;
/// Freshness of a contact, based on when it was last seen.
///
/// Used by the UI to highlight contacts:
/// recently seen contacts get a little green dot on the avatar,
/// contacts not seen for a long time get a string below the name (e.g. "Seen 2 months ago").
#[derive(Debug, PartialEq, Eq)]
pub enum Freshness {
/// Contact shall not be highlighted.
Normal = 0,
/// Contact was seen recently.
RecentlySeen = 1,
/// Contact was not seen for a long time.
Old = 2,
}
impl From<Freshness> for u32 {
fn from(freshness: Freshness) -> Self {
freshness as u32
}
}
/// Contact ID, including reserved IDs.
///
/// Some contact IDs are reserved to identify special contacts. This
@@ -694,9 +718,9 @@ impl Contact {
.await?
.unwrap_or_default();
contact.addr = context
.get_primary_self_addr()
.await
.context("Cannot get address for self-contact")?;
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default();
if let Some(self_fp) = self_fingerprint_opt(context).await? {
contact.fingerprint = Some(self_fp.to_string());
}
@@ -725,10 +749,23 @@ impl Contact {
self.last_seen
}
/// Returns `true` if this contact was seen recently.
#[expect(clippy::arithmetic_side_effects)]
pub fn was_seen_recently(&self) -> bool {
time() - self.last_seen <= SEEN_RECENTLY_SECONDS
/// Returns freshness of the contact.
pub fn get_freshness(&self) -> Freshness {
if self.id.is_special() || !self.is_key_contact() || self.is_blocked() {
return Freshness::Normal;
}
let is_old = time().saturating_sub(self.last_seen) > CONTACT_OLD_SECONDS;
if is_old || self.last_seen <= 0 {
return Freshness::Old;
}
let seen_recently = time().saturating_sub(self.last_seen) <= SEEN_RECENTLY_SECONDS;
if seen_recently {
return Freshness::RecentlySeen;
}
Freshness::Normal
}
/// Check if a contact is blocked.
@@ -1384,6 +1421,10 @@ 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));
@@ -1403,26 +1444,30 @@ WHERE addr=?
.await?
.dc_fingerprint()
.human_readable();
if fingerprint_self < fingerprint_other {
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,
);
} 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,
);
}
@@ -1923,8 +1968,8 @@ pub(crate) async fn update_last_seen(
Ok(())
}
fn cat_fingerprint(ret: &mut String, name: &str, fingerprint: &str) {
*ret += &format!("\n\n{name}:\n{fingerprint}");
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
}
fn split_address_book(book: &str) -> Vec<(&str, &str)> {
+54 -12
View File
@@ -887,11 +887,11 @@ async fn test_contact_get_encrinfo() -> Result<()> {
"Messages are end-to-end encrypted.
Fingerprints:
Me:
Me (alice@example.org):
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 +908,11 @@ bob@example.net"
"No encryption.
Fingerprints:
Me:
Me (alice@example.org):
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"
);
@@ -1048,7 +1048,7 @@ async fn test_last_seen() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_was_seen_recently() -> Result<()> {
async fn test_contact_freshness() -> Result<()> {
let _n = TimeShiftFalsePositiveNote;
let mut tcm = TestContextManager::new();
@@ -1061,15 +1061,57 @@ async fn test_was_seen_recently() -> Result<()> {
let chat = bob.create_chat(&alice).await;
let contacts = chat::get_chat_contacts(&bob, chat.id).await?;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(!contact.was_seen_recently());
assert_eq!(contact.get_freshness(), Freshness::Old);
bob.recv_msg(&sent_msg).await;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(contact.was_seen_recently());
assert_eq!(contact.get_freshness(), Freshness::RecentlySeen);
let self_contact = Contact::get_by_id(&bob, ContactId::SELF).await?;
assert!(!self_contact.was_seen_recently());
assert_eq!(self_contact.get_freshness(), Freshness::Normal);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_freshness_blocked() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// Alice sends message to Bob. Bob receives message, contact's freshness is "recently seen"
let msg = tcm.send_recv(&alice, &bob, "moin").await;
let contact = Contact::get_by_id(&bob, msg.from_id).await?;
assert_eq!(contact.get_freshness(), Freshness::RecentlySeen);
// Bob blocks Alice, contact's freshness is "normal" now
Contact::block(&bob, msg.from_id).await?;
let contact = Contact::get_by_id(&bob, msg.from_id).await?;
assert!(contact.is_blocked());
assert_eq!(contact.get_freshness(), Freshness::Normal);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_freshness_address_contact() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice.allow_unencrypted().await?;
let bob = tcm.bob().await;
bob.allow_unencrypted().await?;
// Alice sends a message to Bob
let alice_chat = alice.create_email_chat(&bob).await;
let sent_msg = alice.send_text(alice_chat.id, "moin").await;
// Bob receives message, contact's freshness is "normal", even though the messages was just received
let msg = bob.recv_msg(&sent_msg).await;
let contact = Contact::get_by_id(&bob, msg.from_id).await?;
assert!(!contact.is_key_contact());
assert_eq!(contact.get_freshness(), Freshness::Normal);
Ok(())
}
@@ -1087,11 +1129,11 @@ async fn test_was_seen_recently_event() -> Result<()> {
let chat = alice.create_chat(&bob).await;
let sent_msg = alice.send_text(chat.id, "moin").await;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(!contact.was_seen_recently());
assert_ne!(contact.get_freshness(), Freshness::RecentlySeen);
bob.evtracker.clear_events();
bob.recv_msg(&sent_msg).await;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(contact.was_seen_recently());
assert_eq!(contact.get_freshness(), Freshness::RecentlySeen);
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
.await;
@@ -1099,12 +1141,12 @@ async fn test_was_seen_recently_event() -> Result<()> {
.interrupt(contact.id, contact.last_seen)
.await;
// Wait for `was_seen_recently()` to turn off.
// Wait for "seen recently" to turn off.
bob.evtracker.clear_events();
SystemTime::shift(Duration::from_secs(SEEN_RECENTLY_SECONDS as u64 * 2));
recently_seen_loop.interrupt(ContactId::UNDEFINED, 0).await;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(!contact.was_seen_recently());
assert_eq!(contact.get_freshness(), Freshness::Normal);
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
.await;
+2
View File
@@ -294,6 +294,8 @@ async fn test_get_info_completeness() {
// too sensitive or summarized in another item.
let skip_from_get_info = vec![
"addr",
"backup_transfer_msg_id",
"backup_transfer_timestamp",
"displayname",
"imap_certificate_checks",
"mail_server",
+23 -9
View File
@@ -363,7 +363,7 @@ pub(crate) async fn start_chat_ephemeral_timers(context: &Context, chat_id: Chat
/// Selects messages which are expired according to
/// `delete_device_after` setting or `ephemeral_timestamp` column.
///
/// For each message a row ID, chat id, viewtype and location ID is returned.
/// For each message a row ID, chat id, viewtype, whether the message is pinned and location ID is returned.
///
/// Unknown viewtypes are returned as `Viewtype::Unknown`
/// and not as errors bubbled up, easily resulting in infinite loop or leaving messages undeleted.
@@ -371,12 +371,12 @@ pub(crate) async fn start_chat_ephemeral_timers(context: &Context, chat_id: Chat
async fn select_expired_messages(
context: &Context,
now: i64,
) -> Result<Vec<(MsgId, ChatId, Viewtype, u32)>> {
) -> Result<Vec<(MsgId, ChatId, Viewtype, bool, u32)>> {
let mut rows = context
.sql
.query_map_vec(
r#"
SELECT id, chat_id, type, location_id
SELECT id, chat_id, type, pinned, location_id
FROM msgs
WHERE
ephemeral_timestamp != 0
@@ -392,8 +392,9 @@ WHERE
.context("Using default viewtype for ephemeral handling.")
.log_err(context)
.unwrap_or_default();
let pinned: bool = row.get("pinned")?;
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
Ok((id, chat_id, viewtype, pinned, location_id))
},
)
.await?;
@@ -414,7 +415,7 @@ WHERE
.sql
.query_map_vec(
r#"
SELECT id, chat_id, type, location_id
SELECT id, chat_id, type, pinned, location_id
FROM msgs
WHERE
timestamp < ?1
@@ -437,8 +438,9 @@ WHERE
.context("Using default viewtype for delete-old handling.")
.log_err(context)
.unwrap_or_default();
let pinned: bool = row.get("pinned")?;
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
Ok((id, chat_id, viewtype, pinned, location_id))
},
)
.await?;
@@ -463,11 +465,15 @@ pub(crate) async fn delete_expired_messages(context: &Context, now: i64) -> Resu
if !rows.is_empty() {
info!(context, "Attempting to delete {} messages.", rows.len());
let (msgs_changed, webxdc_deleted) = context
let (msgs_changed, webxdc_deleted, pinned_chat_ids) = context
.sql
.transaction(|transaction| {
let mut msgs_changed = Vec::with_capacity(rows.len());
let mut webxdc_deleted = Vec::new();
// IDs of the chats in which pinned messages were deleted.
let mut pinned_chat_ids = BTreeSet::new();
// If you change which information is preserved here, also change `MsgId::trash()`
// and other places it references.
let mut del_msg_stmt = transaction.prepare(
@@ -478,7 +484,7 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
)?;
let mut del_location_stmt =
transaction.prepare("DELETE FROM locations WHERE independent=1 AND id=?")?;
for (msg_id, chat_id, viewtype, location_id) in rows {
for (msg_id, chat_id, viewtype, is_pinned, location_id) in rows {
del_msg_stmt.execute((msg_id, ChatId::TRASH))?;
if location_id > 0 {
del_location_stmt.execute((location_id,))?;
@@ -488,8 +494,12 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
if viewtype == Viewtype::Webxdc {
webxdc_deleted.push(msg_id)
}
if is_pinned {
pinned_chat_ids.insert(chat_id);
}
}
Ok((msgs_changed, webxdc_deleted))
Ok((msgs_changed, webxdc_deleted, pinned_chat_ids))
})
.await?;
@@ -504,6 +514,10 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
context.emit_msgs_changed_without_msg_id(modified_chat_id);
}
for chat_id in pinned_chat_ids {
context.emit_event(EventType::PinnedMessagesChanged { chat_id });
}
for msg_id in webxdc_deleted {
context.emit_event(EventType::WebxdcInstanceDeleted { msg_id });
}
+8 -3
View File
@@ -33,9 +33,6 @@ pub enum EventType {
/// Emitted when an IMAP message has been marked as deleted
ImapMessageDeleted(String),
/// Emitted when an IMAP message has been moved
ImapMessageMoved(String),
/// Emitted before going into IDLE on the Inbox folder.
ImapInboxIdle,
@@ -96,6 +93,14 @@ pub enum EventType {
contact_id: ContactId,
},
/// The list of pinned messages for the chat has changed.
///
/// Some message got pinned, or pinned message is unpinned or deleted.
PinnedMessagesChanged {
/// ID of the chat where the list of pinned messages changed.
chat_id: ChatId,
},
/// A reaction to one's own sent message received.
/// Typically, the UI will show a notification for that.
///
+24 -104
View File
@@ -301,7 +301,8 @@ 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?;
let mut first_error = None;
let mut first_connection_error = None;
let mut first_login_error = None;
'candidate: for lp in login_params {
info!(context, "IMAP trying to connect to {}.", lp.connection);
let connection_candidate = lp.connection.clone();
@@ -317,7 +318,7 @@ impl Imap {
Ok(client) => client,
Err(err) => {
warn!(context, "{err:#}.");
first_error.get_or_insert(err);
first_connection_error.get_or_insert(err);
continue 'candidate;
}
};
@@ -396,12 +397,14 @@ impl Imap {
Err(err) => {
warn!(context, "{err:#}.");
first_error.get_or_insert(err);
first_login_error.get_or_insert(err);
}
}
}
Err(first_error.unwrap_or_else(|| format_err!("No IMAP connection candidates provided")))
Err(first_login_error
.or(first_connection_error)
.unwrap_or_else(|| format_err!("No IMAP connection candidates provided")))
}
/// Prepare a new IMAP session.
@@ -420,13 +423,13 @@ impl Imap {
Ok(session)
}
/// FETCH-MOVE-DELETE iteration.
/// FETCH-and-DELETE iteration.
///
/// Prefetches headers and downloads new message from the folder, moves messages away from the
/// folder and deletes messages in the folder.
/// Prefetches headers and downloads new message from the folder
/// and deletes messages in the folder.
///
/// Returns true if at least one message was fetched.
pub async fn fetch_move_delete(
pub async fn fetch_delete(
&mut self,
context: &Context,
session: &mut Session,
@@ -457,9 +460,9 @@ impl Imap {
.context("delete_expired_imap_messages")?;
session
.move_delete_messages(context, watch_folder)
.delete_messages(context, watch_folder)
.await
.context("move_delete_messages")?;
.context("delete_messages")?;
Ok(msgs_fetched)
}
@@ -570,16 +573,6 @@ impl Imap {
.size
.context("imap fetch response does not contain size")?;
// Determine the target folder where the message should be moved to.
//
// We only move the messages from the INBOX and Spam folders.
// This is required to avoid infinite MOVE loop on IMAP servers
// that alias `DeltaChat` folder to other names.
// For example, some Dovecot servers alias `DeltaChat` folder to `INBOX.DeltaChat`.
// In this case moving from `INBOX.DeltaChat` to `DeltaChat`
// results in the messages getting a new UID,
// so the messages will be detected as new
// in the `INBOX.DeltaChat` folder again.
let delete = if let Some(message_id) = &message_id {
message::rfc724_mid_exists_ext(context, message_id, "deleted=1")
.await?
@@ -617,13 +610,7 @@ impl Imap {
)
.await?;
// Download only the messages which have reached their target folder if there are
// multiple devices. This prevents race conditions in multidevice case, where one
// device tries to download the message while another device moves the message at the
// same time. Even in single device case it is possible to fail downloading the first
// message, move it to the movebox and then download the second message before
// downloading the first one, if downloading from inbox before moving is allowed.
if folder == target
if !delete
&& prefetch_should_download(context, &headers, &message_id, fetch_response.flags())
.await
.context("prefetch_should_download")?
@@ -860,75 +847,10 @@ impl Session {
Ok(())
}
/// Moves batch of messages identified by their UID from the currently
/// selected folder to the target folder.
async fn move_message_batch(
&mut self,
context: &Context,
set: &str,
row_ids: Vec<i64>,
target: &str,
) -> Result<()> {
if self.can_move() {
match self.uid_mv(set, &target).await {
Ok(()) => {
// Messages are moved or don't exist, IMAP returns OK response in both cases.
context
.sql
.transaction(|transaction| {
let mut stmt = transaction.prepare("DELETE FROM imap WHERE id = ?")?;
for row_id in row_ids {
stmt.execute((row_id,))?;
}
Ok(())
})
.await
.context("Cannot delete moved messages from imap table")?;
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} moved to {target}"
)));
return Ok(());
}
Err(err) => {
warn!(
context,
"Cannot move messages, fallback to COPY/DELETE {} to {}: {}",
set,
target,
err
);
}
}
}
// Server does not support MOVE or MOVE failed.
// Copy messages to the destination folder if needed and mark records for deletion.
info!(
context,
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
);
self.uid_copy(&set, &target).await?;
context
.sql
.transaction(|transaction| {
let mut stmt = transaction.prepare("UPDATE imap SET target='' WHERE id = ?")?;
for row_id in row_ids {
stmt.execute((row_id,))?;
}
Ok(())
})
.await
.context("Cannot plan deletion of messages")?;
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} copied to {target}"
)));
Ok(())
}
/// Moves and deletes messages as planned in the `imap` table.
/// Deletes messages as planned in the `imap` table.
///
/// This is the only place where messages are moved or deleted on the IMAP server.
async fn move_delete_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
/// This is the only place where messages are deleted on the IMAP server.
async fn delete_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
let transport_id = self.transport_id();
let rows = context
.sql
@@ -950,23 +872,21 @@ impl Session {
for (target, rowid_set, uid_set) in UidGrouper::from(rows) {
// Select folder inside the loop to avoid selecting it if there are no pending
// MOVE/DELETE operations. This does not result in multiple SELECT commands
// DELETE operations. This does not result in multiple SELECT commands
// being sent because `select_folder()` does nothing if the folder is already
// selected.
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No folder {folder}");
// Empty target folder name means messages should be deleted.
// Since we don't move messages between IMAP folders anymore,
// `target` is always either empty or equal to `folder`.
debug_assert!(target.is_empty() || folder == target);
if target.is_empty() {
self.delete_message_batch(context, &uid_set, rowid_set)
.await
.with_context(|| format!("cannot delete batch of messages {uid_set:?}"))?;
} else {
self.move_message_batch(context, &uid_set, rowid_set, &target)
.await
.with_context(|| {
format!("cannot move batch of messages {uid_set:?} to folder {target:?}",)
})?;
}
}
@@ -1534,7 +1454,7 @@ impl Session {
/// or flags have been changed.
/// In this case we may want to skip next IDLE and do a round
/// of fetching new messages and synchronizing seen flags.
fn drain_unsolicited_responses(&self, context: &Context) -> Result<bool> {
fn drain_unsolicited_responses(&self, context: &Context) -> bool {
use UnsolicitedResponse::*;
use async_imap::imap_proto::Response;
use async_imap::imap_proto::ResponseCode;
@@ -1579,7 +1499,7 @@ impl Session {
}
}
}
Ok(should_refetch)
should_refetch
}
}
-4
View File
@@ -9,10 +9,6 @@ pub(crate) struct Capabilities {
/// <https://tools.ietf.org/html/rfc2177>
pub can_idle: bool,
/// True if the server has MOVE capability as defined in
/// <https://tools.ietf.org/html/rfc6851>
pub can_move: bool,
/// True if the server has QUOTA capability as defined in
/// <https://tools.ietf.org/html/rfc2087>
pub can_check_quota: bool,
-1
View File
@@ -78,7 +78,6 @@ pub(crate) async fn identify_server(
};
let capabilities = Capabilities {
can_idle: caps.has_str("IDLE"),
can_move: caps.has_str("MOVE"),
can_check_quota: caps.has_str("QUOTA"),
can_metadata: caps.has_str("METADATA"),
can_compress: caps.has_str("COMPRESS=DEFLATE"),
+1 -1
View File
@@ -31,7 +31,7 @@ impl Session {
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context)? {
if self.drain_unsolicited_responses(context) {
self.new_mail = true;
}
-4
View File
@@ -99,10 +99,6 @@ impl Session {
self.capabilities.can_idle
}
pub fn can_move(&self) -> bool {
self.capabilities.can_move
}
pub fn can_check_quota(&self) -> bool {
self.capabilities.can_check_quota
}
+1
View File
@@ -30,6 +30,7 @@ use crate::tools::{
mod transfer;
use ::pgp::types::KeyDetails;
pub(crate) use transfer::maybe_readd_backup_transfer_msg;
pub use transfer::{BackupProvider, get_backup};
// Name of the database file in the backup.
+97 -7
View File
@@ -40,21 +40,68 @@ use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::EventType;
use crate::chat::add_device_msg;
use crate::chat::add_device_msg_with_importance;
use crate::config::Config;
use crate::context::Context;
use crate::imex::BlobDirContents;
use crate::key;
use crate::log::warn;
use crate::message::Message;
use crate::message::{Message, MsgId};
use crate::qr::Qr;
use crate::stock_str::backup_transfer_msg_body;
use crate::tools::{TempPathGuard, create_id};
use crate::tools::{TempPathGuard, create_id, time};
use super::{DBFILE_BACKUP_NAME, export_backup_stream, export_database, import_backup_stream};
/// ALPN protocol identifier for the backup transfer protocol.
const BACKUP_ALPN: &[u8] = b"/deltachat/backup";
/// Minimum time after a backup transfer before we check
/// whether the "second device added" device message still exists.
///
/// The check is done in the first housekeeping after this delay;
/// if the message was deleted until then, it is re-added with the original timestamp.
/// After the check, the user can delete the message as usual without it being re-added.
///
/// This makes it easier to spot unwanted "add second device" actions.
/// It is clear that this does not catch all eventualities, it is best-effort.
/// First line of defense is device locking and asking for secret explicitly before adding a second device.
const READD_BACKUP_TRANSFER_MSG_DELAY: i64 = 60 * 60;
/// Checks `Config::BackupTransferMsgId`
/// and re-adds the backup transfer device message if it does not exist and some time passed.
pub(crate) async fn maybe_readd_backup_transfer_msg(context: &Context) -> Result<()> {
let Some(msg_id) = context
.get_config_parsed::<u32>(Config::BackupTransferMsgId)
.await?
else {
return Ok(());
};
let timestamp = context
.get_config_i64(Config::BackupTransferTimestamp)
.await?;
if time() < timestamp.saturating_add(READD_BACKUP_TRANSFER_MSG_DELAY) {
return Ok(());
}
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
if Message::load_from_db_optional(context, MsgId::new(msg_id))
.await?
.is_none()
{
let mut msg = Message::new_text(backup_transfer_msg_body(context));
add_device_msg_with_importance(context, None, Some(&mut msg), false, timestamp).await?;
}
Ok(())
}
/// Provide or send a backup of this device.
///
/// This creates a backup of the current device and starts a service which offers another
@@ -212,8 +259,23 @@ impl BackupProvider {
info!(context, "Received backup reception acknowledgement.");
context.emit_event(EventType::ImexProgress(1000));
let timestamp = time();
let mut msg = Message::new_text(backup_transfer_msg_body(&context));
add_device_msg(&context, None, Some(&mut msg)).await?;
let msg_id =
add_device_msg_with_importance(&context, None, Some(&mut msg), false, timestamp)
.await?;
context
.set_config_internal(
Config::BackupTransferTimestamp,
Some(&timestamp.to_string()),
)
.await?;
context
.set_config_internal(
Config::BackupTransferMsgId,
Some(&msg_id.to_u32().to_string()),
)
.await?;
Ok(())
}
@@ -393,14 +455,17 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
mod tests {
use std::time::Duration;
use crate::chat::{ChatItem, get_chat_msgs, send_msg};
use crate::message::Viewtype;
use crate::chat::{ChatId, ChatItem, get_chat_msgs, send_msg};
use crate::contact::ContactId;
use crate::message::{Viewtype, delete_msgs};
use crate::sql::housekeeping;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_receive() {
async fn test_send_receive() -> Result<()> {
let mut tcm = TestContextManager::new();
// Create first device.
@@ -472,6 +537,31 @@ mod tests {
.get_matching(|ev| matches!(ev, EventType::ImexProgress(1000)))
.await;
}
// When deleting the backup transfer message on the sending device,
// it is re-added once on housekeeping,
// but not within the first hour after the transfer.
let device_chat_id = ChatId::get_for_contact(&ctx0, ContactId::DEVICE).await?;
let original_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_eq!(original_msg.text, backup_transfer_msg_body(&ctx0));
delete_msgs(&ctx0, &[original_msg.id]).await?;
housekeeping(&ctx0).await?;
assert!(get_chat_msgs(&ctx0, device_chat_id).await?.is_empty()); // re-adding not done in first hour
SystemTime::shift(Duration::from_secs(60 * 60));
housekeeping(&ctx0).await?;
let readded_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_ne!(readded_msg.id, original_msg.id);
assert_eq!(readded_msg.get_text(), original_msg.get_text());
assert_eq!(readded_msg.get_timestamp(), original_msg.get_timestamp());
assert!(readded_msg.get_sort_timestamp() > original_msg.get_sort_timestamp());
delete_msgs(&ctx0, &[readded_msg.id]).await?;
housekeeping(&ctx0).await?;
let device_msgs = get_chat_msgs(&ctx0, device_chat_id).await?;
assert!(device_msgs.is_empty()); // re-adding is done only once
Ok(())
}
/// Tests that trying to accidentally overwrite a profile
+2 -1
View File
@@ -98,7 +98,8 @@ pub trait DcKey: Serialize + Deserializable + Clone {
// Because we write to a Vec<u8> the io::Write impls never
// fail and we can hide this error.
let mut buf = Vec::new();
self.to_writer(&mut buf).unwrap();
self.to_writer(&mut buf)
.expect("Writing to Vec<u8> cannot fail");
buf
}
+2 -1
View File
@@ -15,7 +15,8 @@
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied,
clippy::manual_is_variant_and
clippy::manual_is_variant_and,
clippy::unnecessary_wraps
)]
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
-95
View File
@@ -9,14 +9,12 @@
use std::fmt;
use anyhow::{Context as _, Result};
use num_traits::ToPrimitive as _;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
pub use crate::net::proxy::ProxyConfig;
pub use crate::provider::Socket;
use crate::tools::ToOption;
/// User-entered setting for certificate checks.
///
@@ -232,62 +230,6 @@ impl EnteredLoginParam {
oauth2: false,
})
}
/// Saves entered account settings,
/// so that they can be prefilled if the user wants to configure the server again.
///
/// This is needed in case a UI is not yet updated, and still uses `get_config("mail_pw")` etc.
/// in order to prefill the entered account settings.
pub(crate) async fn save_legacy(&self, context: &Context) -> Result<()> {
context.set_config(Config::Addr, Some(&self.addr)).await?;
context
.set_config(Config::MailServer, self.imap.server.to_option())
.await?;
context
.set_config(Config::MailPort, self.imap.port.to_option().as_deref())
.await?;
context
.set_config(
Config::MailSecurity,
self.imap.security.to_i32().to_option().as_deref(),
)
.await?;
context
.set_config(Config::MailUser, self.imap.user.to_option())
.await?;
context
.set_config(Config::MailPw, self.imap.password.to_option())
.await?;
context
.set_config(Config::SendServer, self.smtp.server.to_option())
.await?;
context
.set_config(Config::SendPort, self.smtp.port.to_option().as_deref())
.await?;
context
.set_config(
Config::SendSecurity,
self.smtp.security.to_i32().to_option().as_deref(),
)
.await?;
context
.set_config(Config::SendUser, self.smtp.user.to_option())
.await?;
context
.set_config(Config::SendPw, self.smtp.password.to_option())
.await?;
context
.set_config(
Config::ImapCertificateChecks,
self.certificate_checks.to_i32().to_option().as_deref(),
)
.await?;
Ok(())
}
}
impl fmt::Display for EnteredLoginParam {
@@ -369,41 +311,4 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_entered_login_param() -> Result<()> {
let t = TestContext::new().await;
let param = EnteredLoginParam {
addr: "alice@example.org".to_string(),
imap: EnteredImapLoginParam {
server: "".to_string(),
port: 0,
folder: "".to_string(),
security: Socket::Starttls,
user: "".to_string(),
password: "foobar".to_string(),
},
smtp: EnteredSmtpLoginParam {
server: "".to_string(),
port: 2947,
security: Socket::default(),
user: "".to_string(),
password: "".to_string(),
},
certificate_checks: Default::default(),
oauth2: false,
};
param.save_legacy(&t).await?;
assert_eq!(
t.get_config(Config::Addr).await?.unwrap(),
"alice@example.org"
);
assert_eq!(t.get_config(Config::MailPw).await?.unwrap(), "foobar");
assert_eq!(t.get_config(Config::SendPw).await?, None);
assert_eq!(t.get_config_int(Config::SendPort).await?, 2947);
assert_eq!(EnteredLoginParam::load_legacy(&t).await?, param);
Ok(())
}
}
+37 -21
View File
@@ -580,7 +580,7 @@ impl Message {
if let Some(msg) = &mut msg {
msg.additional_text =
Self::get_additional_text(context, msg.download_state, &msg.param)?;
Self::get_additional_text(context, msg.download_state, &msg.param);
}
Ok(msg)
@@ -618,7 +618,7 @@ impl Message {
context: &Context,
download_state: DownloadState,
param: &Params,
) -> Result<String> {
) -> String {
if download_state != DownloadState::Done {
let file_size = param
.get(Param::PostMessageFileBytes)
@@ -635,14 +635,14 @@ impl Message {
.unwrap_or("?".to_owned());
return match viewtype {
Viewtype::File => Ok(format!(" [{file_name} – {file_size}]")),
Viewtype::File => format!(" [{file_name} – {file_size}]"),
_ => {
let translated_viewtype = viewtype.to_locale_string(context);
Ok(format!(" [{translated_viewtype} – {file_size}]"))
format!(" [{translated_viewtype} – {file_size}]")
}
};
}
Ok(String::new())
String::new()
}
/// Returns the MIME type of an attached file if it exists.
@@ -1445,13 +1445,10 @@ impl std::fmt::Display for MessageState {
}
impl MessageState {
/// Returns true if the message can transition to `OutFailed` state from the current state.
/// Returns true if the message can be marked as failed in the current state.
pub fn can_fail(self) -> bool {
use MessageState::*;
matches!(
self,
OutPending | OutDelivered | OutMdnRcvd // OutMdnRcvd can still fail because it could be a group message and only some recipients failed.
)
matches!(self, OutPending | OutDelivered | OutFailed)
}
/// Returns true for any outgoing message states.
@@ -1644,11 +1641,15 @@ pub(crate) async fn delete_msgs_locally_done(
context: &Context,
msg_ids: &[MsgId],
modified_chat_ids: BTreeSet<ChatId>,
pinned_messages_changed_chat_ids: BTreeSet<ChatId>,
) -> Result<()> {
for modified_chat_id in modified_chat_ids {
context.emit_msgs_changed_without_msg_id(modified_chat_id);
chatlist_events::emit_chatlist_item_changed(context, modified_chat_id);
}
for chat_id in pinned_messages_changed_chat_ids {
context.emit_event(EventType::PinnedMessagesChanged { chat_id });
}
if !msg_ids.is_empty() {
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
@@ -1674,6 +1675,7 @@ pub async fn delete_msgs_ext(
delete_for_all: bool,
) -> Result<()> {
let mut modified_chat_ids = BTreeSet::new();
let mut pinned_messages_changed_chat_ids = BTreeSet::new();
let mut deleted_rfc724_mid = Vec::new();
let mut res = Ok(());
@@ -1689,6 +1691,9 @@ pub async fn delete_msgs_ext(
);
modified_chat_ids.insert(msg.chat_id);
if msg.is_pinned() {
pinned_messages_changed_chat_ids.insert(msg.chat_id);
}
deleted_rfc724_mid.push(msg.rfc724_mid.clone());
let update_db = |trans: &mut rusqlite::Transaction| {
@@ -1746,7 +1751,13 @@ pub async fn delete_msgs_ext(
let msg = Message::load_from_db(context, msg_id).await?;
delete_msg_locally(context, &msg).await?;
}
delete_msgs_locally_done(context, msg_ids, modified_chat_ids).await?;
delete_msgs_locally_done(
context,
msg_ids,
modified_chat_ids,
pinned_messages_changed_chat_ids,
)
.await?;
// Interrupt Inbox loop to start message deletion, run housekeeping and call send_sync_msg().
context.scheduler.interrupt_inbox().await;
@@ -1975,15 +1986,15 @@ pub(crate) async fn set_msg_failed(
msg: &mut Message,
error: &str,
) -> Result<()> {
if msg.state.can_fail() {
msg.state = MessageState::OutFailed;
warn!(context, "{} failed: {}", msg.id, error);
} else {
warn!(
if !msg.state.can_fail() {
info!(
context,
"{} seems to have failed ({}), but state is {}", msg.id, error, msg.state
)
"Ignoring failed {} in state {}: {}", msg.id, msg.state, error
);
return Ok(());
}
msg.state = MessageState::OutFailed;
warn!(context, "{} failed: {}", msg.id, error);
msg.error = Some(error.to_string());
let exists = context
@@ -2006,14 +2017,19 @@ pub(crate) async fn set_msg_failed(
/// Inserts a tombstone into `msgs` table
/// to prevent downloading the same message in the future.
/// 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, rfc724_mid: &str) -> Result<MsgId> {
pub(crate) async fn insert_tombstone(
context: &Context,
rfc724_mid: &str,
on_server: bool,
) -> Result<MsgId> {
let row_id = context
.sql
.insert(
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
(rfc724_mid, ChatId::TRASH),
"INSERT INTO msgs(rfc724_mid, chat_id, deleted) VALUES (?,?,?)",
(rfc724_mid, ChatId::TRASH, on_server),
)
.await?;
let msg_id = MsgId::new(u32::try_from(row_id)?);
+40
View File
@@ -454,6 +454,28 @@ async fn test_get_state() -> Result<()> {
Ok(())
}
/// Tests that a failure reported after a read receipt leaves the message untouched.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_msg_failed_after_mdn() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
let sent = alice.send_text(alice_chat.id, "hi").await;
let bob_msg = bob.recv_msg(&sent).await;
alice.recv_mdn(bob, &bob_msg).await?;
let mut msg = sent.load_from_db().await;
assert_eq!(msg.state, MessageState::OutMdnRcvd);
set_msg_failed(alice, &mut msg, "relay bounced").await?;
let msg = sent.load_from_db().await;
assert_eq!(msg.state, MessageState::OutMdnRcvd);
assert_eq!(msg.error(), None);
let chats = Chatlist::try_load(alice, 0, None, None).await?;
assert_eq!(chats.get_msg_id(0)?, Some(msg.id));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_bot() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -760,3 +782,21 @@ async fn test_get_existing_msg_ids() -> Result<()> {
Ok(())
}
#[test]
fn test_can_fail() {
use MessageState::*;
// states that are not allowed to transition to OutFailed
assert!(!Undefined.can_fail());
assert!(!InFresh.can_fail());
assert!(!InNoticed.can_fail());
assert!(!InSeen.can_fail());
assert!(!OutDraft.can_fail());
assert!(!OutMdnRcvd.can_fail());
// states that are allowed to transition to OutFailed
assert!(OutPending.can_fail());
assert!(OutDelivered.can_fail());
assert!(OutFailed.can_fail());
}
+339 -413
View File
@@ -26,7 +26,6 @@ use crate::download::PostMsgMetadata;
use crate::ensure_and_debug_assert;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::headerdef::HeaderDef;
use crate::key;
use crate::key::{DcKey, SignedPublicKey, SignedSecretKey, load_self_public_key, self_fingerprint};
use crate::location;
use crate::log::warn;
@@ -57,19 +56,6 @@ use crate::webxdc::StatusUpdateSerial;
/// and divide by 4/3 to account for base64 encoding.
pub const RECOMMENDED_FILE_SIZE: u64 = (30 - 1) * 1024 * 1024 / 4 * 3;
#[derive(Debug, Clone)]
#[expect(clippy::large_enum_variant)]
pub enum Loaded {
Message {
chat: Chat,
msg: Message,
},
Mdn {
rfc724_mid: String,
additional_msg_ids: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum PreMessageMode {
/// adds the Chat-Is-Post-Message header in unprotected part
@@ -185,7 +171,10 @@ pub struct MimeFactory {
member_timestamps: Vec<i64>,
timestamp: i64,
loaded: Loaded,
chat: Chat,
msg: Message,
in_reply_to: String,
/// List of Message-IDs for `References` header.
@@ -453,8 +442,8 @@ pub(crate) async fn render_queued_mail_with_context(
context: &Context,
) -> Result<RenderedEmail> {
let from_addr = context.get_primary_self_addr().await?;
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let public_key = crate::key::load_self_public_key(context).await?;
let secret_key = crate::key::load_self_secret_key(context).await?;
let rendered_mail = render_queued_mail(queued_mail, &public_key, &secret_key, from_addr)?;
Ok(rendered_mail)
@@ -511,11 +500,6 @@ impl MimeFactory {
let mut member_fingerprints = Vec::new();
let mut member_timestamps = Vec::new();
let mut recipient_ids = HashSet::new();
let req_mdn = !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let self_fingerprint = self_fingerprint(context).await?;
let encryption = if chat.is_self_talk() {
@@ -796,6 +780,12 @@ impl MimeFactory {
}
};
let req_mdn = encryption.is_encrypted()
&& !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let (in_reply_to, references) = context
.sql
.query_row(
@@ -849,7 +839,8 @@ impl MimeFactory {
member_fingerprints,
member_timestamps,
timestamp: msg.timestamp_sort,
loaded: Loaded::Message { msg, chat },
chat,
msg,
in_reply_to,
references,
req_mdn,
@@ -860,62 +851,6 @@ impl MimeFactory {
Ok(factory)
}
pub async fn from_mdn(
context: &Context,
from_id: ContactId,
rfc724_mid: String,
additional_msg_ids: Vec<String>,
) -> Result<MimeFactory> {
let contact = Contact::get_by_id(context, from_id).await?;
let from_addr = context.get_primary_self_addr().await?;
let timestamp = time();
let addr = contact.get_addr().to_string();
let mut recipients = vec![addr.clone()];
let encryption = if from_id == ContactId::SELF {
Encryption::Asymmetric {
encryption_pubkeys: Vec::new(),
}
} else if contact.is_key_contact() {
let encryption_pubkeys = if let Some(key) = contact.public_key(context).await? {
recipients = relay_addrs(&key, &addr);
vec![(addr.clone(), key)]
} else {
Vec::new()
};
Encryption::Asymmetric { encryption_pubkeys }
} else {
Encryption::No
};
let res = MimeFactory {
from_addr,
from_displayname: "".to_string(),
sender_displayname: None,
selfstatus: "".to_string(),
recipients,
encryption,
to: vec![("".to_string(), contact.get_addr().to_string())],
past_members: vec![],
member_fingerprints: vec![],
member_timestamps: vec![],
timestamp,
loaded: Loaded::Mdn {
rfc724_mid,
additional_msg_ids,
},
in_reply_to: String::default(),
references: Vec::new(),
req_mdn: false,
attach_selfavatar: false,
webxdc_topic: None,
pre_message_mode: PreMessageMode::None,
};
Ok(res)
}
/// Returns whether own Autocrypt key should be attached to this MDN
/// and if so, records the attachment.
///
@@ -925,15 +860,15 @@ impl MimeFactory {
/// so that contacts we only read messages from
/// still learn our current key and relay list
/// and will likely re-gossip it to group chats.
async fn update_mdn_pubkey_attachment(&self, context: &Context) -> Result<bool> {
let Encryption::Asymmetric { encryption_pubkeys } = &self.encryption else {
return Ok(false);
};
async fn update_mdn_pubkey_attachment(
context: &Context,
encryption_pubkeys: &[SignedPublicKey],
) -> Result<bool> {
debug_assert!(
encryption_pubkeys.len() <= 1,
"MDNs have at most one recipient key; own key is only added at encryption time"
);
let [(_, ref key)] = encryption_pubkeys[..] else {
let [ref key] = encryption_pubkeys[..] else {
return Ok(false);
};
let fingerprint = key.dc_fingerprint().hex();
@@ -1015,80 +950,69 @@ impl MimeFactory {
}
fn grpimage(&self) -> Option<String> {
match &self.loaded {
Loaded::Message { chat, msg } => {
let cmd = msg.param.get_cmd();
let cmd = self.msg.param.get_cmd();
match cmd {
SystemMessage::MemberAddedToGroup => {
return chat.param.get(Param::ProfileImage).map(Into::into);
}
SystemMessage::GroupImageChanged => {
return msg.param.get(Param::Arg).map(Into::into);
}
_ => {}
}
if msg
.param
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
return chat.param.get(Param::ProfileImage).map(Into::into);
}
None
match cmd {
SystemMessage::MemberAddedToGroup => {
return self.chat.param.get(Param::ProfileImage).map(Into::into);
}
Loaded::Mdn { .. } => None,
SystemMessage::GroupImageChanged => {
return self.msg.param.get(Param::Arg).map(Into::into);
}
_ => {}
}
if self
.msg
.param
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
return self.chat.param.get(Param::ProfileImage).map(Into::into);
}
None
}
async fn subject_str(&self, context: &Context) -> Result<String> {
let subject = match &self.loaded {
Loaded::Message { chat, msg } => {
let quoted_msg_subject = msg.quoted_message(context).await?.map(|m| m.subject);
let quoted_msg_subject = self.msg.quoted_message(context).await?.map(|m| m.subject);
if !msg.subject.is_empty() {
return Ok(msg.subject.clone());
}
if !self.msg.subject.is_empty() {
return Ok(self.msg.subject.clone());
}
if (chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast)
&& quoted_msg_subject.is_none_or_empty()
{
let re = if self.in_reply_to.is_empty() {
""
} else {
"Re: "
};
return Ok(format!("{}{}", re, chat.name));
}
if (self.chat.typ == Chattype::Group || self.chat.typ == Chattype::OutBroadcast)
&& quoted_msg_subject.is_none_or_empty()
{
let re = if self.in_reply_to.is_empty() {
""
} else {
"Re: "
};
return Ok(format!("{}{}", re, self.chat.name));
}
let parent_subject = if quoted_msg_subject.is_none_or_empty() {
chat.param.get(Param::LastSubject)
} else {
quoted_msg_subject.as_deref()
};
if let Some(last_subject) = parent_subject {
return Ok(format!("Re: {}", remove_subject_prefix(last_subject)));
}
let self_name = match Self::should_attach_profile_data(msg) {
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(),
};
stock_str::subject_for_new_contact(context, self_name)
}
Loaded::Mdn { .. } => "Receipt Notification".to_string(), // untranslated to no reveal sender's language
let parent_subject = if quoted_msg_subject.is_none_or_empty() {
self.chat.param.get(Param::LastSubject)
} else {
quoted_msg_subject.as_deref()
};
if let Some(last_subject) = parent_subject {
return Ok(format!("Re: {}", remove_subject_prefix(last_subject)));
}
Ok(subject)
let self_name = match Self::should_attach_profile_data(&self.msg) {
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))
}
pub fn recipients(&self) -> Vec<String> {
@@ -1165,10 +1089,10 @@ impl MimeFactory {
));
}
if let Loaded::Message { chat, .. } = &self.loaded
&& chat.typ == Chattype::Group
{
if !self.member_timestamps.is_empty() && !chat.member_list_is_stale(context).await? {
if self.chat.typ == Chattype::Group {
if !self.member_timestamps.is_empty()
&& !self.chat.member_list_is_stale(context).await?
{
headers.push((
"Chat-Group-Member-Timestamps",
mail_builder::headers::raw::Raw::new(
@@ -1220,29 +1144,25 @@ impl MimeFactory {
}
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
if let Loaded::Mdn { .. } = self.loaded {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
));
} else if context.get_config_bool(Config::Bot).await? {
if context.get_config_bool(Config::Bot).await? {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
));
}
if let Loaded::Message { msg, chat } = &self.loaded
&& (chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast)
{
if self.chat.typ == Chattype::OutBroadcast || self.chat.typ == Chattype::InBroadcast {
headers.push((
"Chat-List-ID",
mail_builder::headers::text::Text::new(format!("{} <{}>", chat.name, chat.grpid))
.into(),
mail_builder::headers::text::Text::new(format!(
"{} <{}>",
self.chat.name, self.chat.grpid
))
.into(),
));
if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup
&& let Some(secret) = msg.param.get(PARAM_BROADCAST_SECRET)
if self.msg.param.get_cmd() == SystemMessage::MemberAddedToGroup
&& let Some(secret) = self.msg.param.get(PARAM_BROADCAST_SECRET)
{
headers.push((
"Chat-Broadcast-Secret",
@@ -1251,22 +1171,18 @@ impl MimeFactory {
}
}
if let Loaded::Message { msg, .. } = &self.loaded {
if let Some(original_rfc724_mid) = msg.param.get(Param::TextEditFor) {
headers.push((
"Chat-Edit",
mail_builder::headers::message_id::MessageId::new(
original_rfc724_mid.to_string(),
)
if let Some(original_rfc724_mid) = self.msg.param.get(Param::TextEditFor) {
headers.push((
"Chat-Edit",
mail_builder::headers::message_id::MessageId::new(original_rfc724_mid.to_string())
.into(),
));
} else if let Some(rfc724_mid_list) = msg.param.get(Param::DeleteRequestFor) {
headers.push((
"Chat-Delete",
mail_builder::headers::message_id::MessageId::new(rfc724_mid_list.to_string())
.into(),
));
}
));
} else if let Some(rfc724_mid_list) = self.msg.param.get(Param::DeleteRequestFor) {
headers.push((
"Chat-Delete",
mail_builder::headers::message_id::MessageId::new(rfc724_mid_list.to_string())
.into(),
));
}
headers.push((
@@ -1303,14 +1219,12 @@ impl MimeFactory {
// Add ephemeral timer for non-MDN messages.
// For MDNs it does not matter because they are not visible
// and ignored by the receiver.
if let Loaded::Message { msg, .. } = &self.loaded {
let ephemeral_timer = msg.chat_id.get_ephemeral_timer(context).await?;
if let EphemeralTimer::Enabled { duration } = ephemeral_timer {
headers.push((
"Ephemeral-Timer",
mail_builder::headers::raw::Raw::new(duration.to_string()).into(),
));
}
let ephemeral_timer = self.msg.chat_id.get_ephemeral_timer(context).await?;
if let EphemeralTimer::Enabled { duration } = ephemeral_timer {
headers.push((
"Ephemeral-Timer",
mail_builder::headers::raw::Raw::new(duration.to_string()).into(),
));
}
Ok(headers)
@@ -1318,19 +1232,14 @@ impl MimeFactory {
/// Helper function render the messages that are not queued.
///
/// Used for MDNs because they are fully rendered and sent in one go,
/// rather than first creating a [`QueuedMail`] and sending it later.
pub async fn render(self, context: &Context, from_addr: &str) -> Result<RenderedEmail> {
/// Used only for tests.
#[cfg(test)]
pub async fn render(self, context: &Context) -> Result<RenderedEmail> {
// Does not matter, we are not going to return the QueuedMail.
let bcc_self = false;
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let (queued_mail, _side_effects) =
Box::pin(self.into_queued_mail(context, bcc_self)).await?;
let rendered_mail =
render_queued_mail(queued_mail, &public_key, &secret_key, from_addr.to_string())?;
Ok(rendered_mail)
render_queued_mail_with_context(queued_mail, context).await
}
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
@@ -1341,18 +1250,15 @@ impl MimeFactory {
context: &Context,
bcc_self: bool,
) -> Result<ToBeQueuedMail> {
let rfc724_mid = match &self.loaded {
Loaded::Message { msg, .. } => match &self.pre_message_mode {
PreMessageMode::Pre { .. } => {
if msg.pre_rfc724_mid.is_empty() {
create_outgoing_rfc724_mid()
} else {
msg.pre_rfc724_mid.clone()
}
let rfc724_mid = match &self.pre_message_mode {
PreMessageMode::Pre { .. } => {
if self.msg.pre_rfc724_mid.is_empty() {
create_outgoing_rfc724_mid()
} else {
self.msg.pre_rfc724_mid.clone()
}
_ => msg.rfc724_mid.clone(),
},
Loaded::Mdn { .. } => create_outgoing_rfc724_mid(),
}
_ => self.msg.rfc724_mid.clone(),
};
let subject_str = self.subject_str(context).await?;
@@ -1362,61 +1268,44 @@ impl MimeFactory {
let is_encrypted = self.will_be_encrypted();
let side_effects: Option<QueueSideEffects>;
let RenderedMessage {
main_part,
mut parts,
last_added_location_timestamp,
avatar_is_attached,
sync_ids_to_delete,
} = self
.render_message(context, &mut headers, &grpimage, is_encrypted)
.await?;
let message: MimePart<'static> = match &self.loaded {
Loaded::Message { msg, .. } => {
let msg = msg.clone();
let RenderedMessage {
main_part,
mut parts,
last_added_location_timestamp,
avatar_is_attached,
sync_ids_to_delete,
} = self
.render_message(context, &mut headers, &grpimage, is_encrypted)
.await?;
let side_effects = QueueSideEffects {
chat_id: self.msg.chat_id,
avatar_is_attached,
sync_ids_to_delete,
last_added_location_timestamp,
subject: subject_str,
};
side_effects = Some(QueueSideEffects {
chat_id: msg.chat_id,
avatar_is_attached,
sync_ids_to_delete,
last_added_location_timestamp,
subject: subject_str,
});
let message: MimePart<'static> = if parts.is_empty() {
// Single part, render as regular message.
main_part
} else {
parts.insert(0, main_part);
if parts.is_empty() {
// Single part, render as regular message.
main_part
} else {
parts.insert(0, main_part);
// Multiple parts, render as multipart.
if msg.param.get_cmd() == SystemMessage::MultiDeviceSync {
MimePart::new("multipart/report; report-type=multi-device-sync", parts)
} else if msg.param.get_cmd() == SystemMessage::WebxdcStatusUpdate {
MimePart::new("multipart/report; report-type=status-update", parts)
} else {
MimePart::new("multipart/mixed", parts)
}
}
}
Loaded::Mdn { .. } => {
side_effects = None;
self.render_mdn()?
// Multiple parts, render as multipart.
if self.msg.param.get_cmd() == SystemMessage::MultiDeviceSync {
MimePart::new("multipart/report; report-type=multi-device-sync", parts)
} else if self.msg.param.get_cmd() == SystemMessage::WebxdcStatusUpdate {
MimePart::new("multipart/report; report-type=status-update", parts)
} else {
MimePart::new("multipart/mixed", parts)
}
};
let should_attach_pubkey = match &self.loaded {
Loaded::Message { .. } => true,
Loaded::Mdn { .. } => self.update_mdn_pubkey_attachment(context).await?,
};
let should_attach_pubkey = true;
let is_post_message = self.pre_message_mode == PreMessageMode::Post;
let is_securejoin_message = match &self.loaded {
Loaded::Message { msg, .. } => msg.param.get_cmd() == SystemMessage::SecurejoinMessage,
Loaded::Mdn { .. } => false,
};
let is_securejoin_message = self.msg.param.get_cmd() == SystemMessage::SecurejoinMessage;
// Disable compression for SecureJoin to ensure
// there are no compression side channels
@@ -1434,79 +1323,70 @@ impl MimeFactory {
let gossip_period = context.get_config_i64(Config::GossipPeriod).await?;
let now = time();
match &self.loaded {
Loaded::Message { chat, msg } => {
if !should_hide_recipients(msg, chat) {
for (addr, key) in encryption_pubkeys {
let fingerprint = key.dc_fingerprint().hex();
let cmd = msg.param.get_cmd();
if is_post_message {
continue;
}
if !should_hide_recipients(&self.msg, &self.chat) {
for (addr, key) in encryption_pubkeys {
let fingerprint = key.dc_fingerprint().hex();
let cmd = self.msg.param.get_cmd();
if is_post_message {
continue;
}
let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup
|| cmd == SystemMessage::SecurejoinMessage
|| multiple_recipients && {
let gossiped_timestamp: Option<i64> = context
.sql
.query_get_value(
"SELECT timestamp
let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup
|| cmd == SystemMessage::SecurejoinMessage
|| multiple_recipients && {
let gossiped_timestamp: Option<i64> = context
.sql
.query_get_value(
"SELECT timestamp
FROM gossip_timestamp
WHERE chat_id=? AND fingerprint=?",
(chat.id, &fingerprint),
)
.await?;
(self.chat.id, &fingerprint),
)
.await?;
// `gossip_period == 0` is a special case for testing,
// enabling gossip in every message.
//
// If current time is in the past compared to
// `gossiped_timestamp`, we also gossip because
// either the `gossiped_timestamp` or clock is wrong.
gossip_period == 0
|| gossiped_timestamp
.is_none_or(|ts| now >= ts + gossip_period || now < ts)
};
// `gossip_period == 0` is a special case for testing,
// enabling gossip in every message.
//
// If current time is in the past compared to
// `gossiped_timestamp`, we also gossip because
// either the `gossiped_timestamp` or clock is wrong.
gossip_period == 0
|| gossiped_timestamp
.is_none_or(|ts| now >= ts + gossip_period || now < ts)
};
if !should_do_gossip {
continue;
}
if !should_do_gossip {
continue;
}
let header = Aheader {
addr: addr.clone(),
public_key: key.clone(),
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
}
.to_string();
let header = Aheader {
addr: addr.clone(),
public_key: key.clone(),
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
}
.to_string();
headers.push((
"Autocrypt-Gossip",
mail_builder::headers::raw::Raw::new(header).into(),
));
headers.push((
"Autocrypt-Gossip",
mail_builder::headers::raw::Raw::new(header).into(),
));
context
.sql
.execute(
"INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp)
context
.sql
.execute(
"INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp)
VALUES (?, ?, ?)
ON CONFLICT (chat_id, fingerprint)
DO UPDATE SET timestamp=excluded.timestamp",
(chat.id, &fingerprint, now),
)
.await?;
}
}
}
Loaded::Mdn { .. } => {
// Never gossip in MDNs.
(self.chat.id, &fingerprint, now),
)
.await?;
}
}
}
let is_encrypted = self.will_be_encrypted();
let display_name = if is_securejoin_message && !is_encrypted {
// Unencrypted securejoin messages should _not_ include the display name.
"".to_string()
@@ -1514,25 +1394,13 @@ impl MimeFactory {
self.from_displayname.clone()
};
let is_mdn = matches!(self.loaded, Loaded::Mdn { .. });
let should_sign = true;
let message = if is_encrypted {
add_headers_to_encrypted_part(message, headers)
} else if is_mdn {
// Never add outer multipart/mixed wrapper to MDN
// as multipart/report Content-Type is used to recognize MDNs
// by Delta Chat receiver and Chatmail servers
// allowing them to be unencrypted and not contain Autocrypt header
// without resetting Autocrypt encryption or triggering Chatmail filter
// that normally only allows encrypted mails.
message
} else {
// Unencrypted message.
let message = if let Loaded::Message { msg, .. } = &self.loaded
&& msg.param.get_cmd() == SystemMessage::SecurejoinMessage
let message = if self.msg.param.get_cmd() == SystemMessage::SecurejoinMessage
&& matches!(
msg.param.get(Param::Arg),
self.msg.param.get(Param::Arg),
Some("vc-request") | Some("vg-request")
) {
// Workaround for legacy SecureJoin {vc,vg}-request messages.
@@ -1545,12 +1413,7 @@ impl MimeFactory {
message
};
headers.iter().fold(message, |message, (header, value)| {
debug_assert_ne!(*header, "from");
debug_assert_ne!(*header, "message-id");
debug_assert_ne!(*header, "autocrypt");
message.header(*header, value.clone())
})
add_headers_to_part(message, headers)
};
let raw_message = part_to_bytes(message);
let recipients = self.recipients();
@@ -1561,7 +1424,7 @@ impl MimeFactory {
display_name,
encryption: self.encryption.into_queued_encryption(),
should_attach_pubkey,
should_sign,
should_sign: true,
should_compress,
recipients,
sent_to: Vec::new(),
@@ -1572,14 +1435,10 @@ impl MimeFactory {
/// Returns MIME part with a `message.kml` attachment.
fn get_message_kml_part(&self) -> Option<MimePart<'static>> {
let Loaded::Message { msg, .. } = &self.loaded else {
return None;
};
let latitude = self.msg.param.get_float(Param::SetLatitude)?;
let longitude = self.msg.param.get_float(Param::SetLongitude)?;
let latitude = msg.param.get_float(Param::SetLatitude)?;
let longitude = msg.param.get_float(Param::SetLongitude)?;
let kml_file = location::get_message_kml(msg.timestamp_sort, latitude, longitude);
let kml_file = location::get_message_kml(self.msg.timestamp_sort, latitude, longitude);
let part = MimePart::new("application/vnd.google-earth.kml+xml", kml_file)
.attachment("message.kml");
Some(part)
@@ -1591,12 +1450,8 @@ impl MimeFactory {
&self,
context: &Context,
) -> Result<Option<(MimePart<'static>, i64)>> {
let Loaded::Message { msg, .. } = &self.loaded else {
return Ok(None);
};
let Some((kml_content, last_added_location_timestamp)) =
location::get_kml(context, msg.chat_id).await?
location::get_kml(context, self.msg.chat_id).await?
else {
return Ok(None);
};
@@ -1613,11 +1468,8 @@ impl MimeFactory {
grpimage: &Option<String>,
is_encrypted: bool,
) -> Result<RenderedMessage> {
let Loaded::Message { chat, msg } = &self.loaded else {
bail!("Attempt to render MDN as a message");
};
let chat = chat.clone();
let msg = msg.clone();
let chat = self.chat.clone();
let msg = self.msg.clone();
let command = msg.param.get_cmd();
let mut placeholdertext = None;
@@ -2153,59 +2005,6 @@ impl MimeFactory {
})
}
/// Render an MDN
fn render_mdn(&mut self) -> Result<MimePart<'static>> {
// RFC 6522, this also requires the `report-type` parameter which is equal
// to the MIME subtype of the second body part of the multipart/report
let Loaded::Mdn {
rfc724_mid,
additional_msg_ids,
} = &self.loaded
else {
bail!("Attempt to render a message as MDN");
};
// first body part: always human-readable, always REQUIRED by RFC 6522.
// untranslated to no reveal sender's language.
// moreover, translations in unknown languages are confusing, and clients may not display them at all
let text_part = MimePart::new("text/plain", "This is a receipt notification.");
let mut message = MimePart::new(
"multipart/report; report-type=disposition-notification",
vec![text_part],
);
// second body part: machine-readable, always REQUIRED by RFC 6522
//
// We do not include the Final-Recipient field.
// According to <https://datatracker.ietf.org/doc/html/rfc8098#section-3.2.4>
// it MUST be present and be the address on which original message was received,
// but practically it is not going to be used.
let message_text2 = format!(
"Original-Message-ID: <{rfc724_mid}>\r\n\
Disposition: manual-action/MDN-sent-automatically; displayed\r\n",
);
let extension_fields = if additional_msg_ids.is_empty() {
"".to_string()
} else {
"Additional-Message-IDs: ".to_string()
+ &additional_msg_ids
.iter()
.map(|mid| render_rfc724_mid(mid))
.collect::<Vec<String>>()
.join(" ")
+ "\r\n"
};
message.add_part(MimePart::new(
"message/disposition-notification",
message_text2 + &extension_fields,
));
Ok(message)
}
pub fn will_be_encrypted(&self) -> bool {
self.encryption.is_encrypted()
}
@@ -2235,20 +2034,26 @@ pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
)
}
fn add_headers_to_part(
message: MimePart<'static>,
headers: Vec<(&'static str, HeaderType<'static>)>,
) -> MimePart<'static> {
headers
.into_iter()
.fold(message, |message, (header, value)| {
debug_assert_ne!(header, "from");
debug_assert_ne!(header, "message-id");
debug_assert_ne!(header, "autocrypt");
message.header(header, value)
})
}
fn add_headers_to_encrypted_part(
message: MimePart<'static>,
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
) -> MimePart<'static> {
// Store protected headers in the inner message.
let mut message: MimePart<'static> =
protected_headers
.into_iter()
.fold(message, |message, (header, value)| {
debug_assert_ne!(header, "from");
debug_assert_ne!(header, "message-id");
debug_assert_ne!(header, "autocrypt");
message.header(header, value)
});
let mut message = add_headers_to_part(message, protected_headers);
// Set the appropriate Content-Type for the inner message
for (h, v) in &mut message.headers {
@@ -2436,6 +2241,127 @@ pub(crate) async fn symm_encrypted_securejoin_message(
Ok(queued_mail)
}
/// Returns an MDN body.
fn mdn_body(rfc724_mid: &str, additional_msg_ids: Vec<String>) -> MimePart<'static> {
// RFC 6522, this also requires the `report-type` parameter which is equal
// to the MIME subtype of the second body part of the multipart/report
// first body part: always human-readable, always REQUIRED by RFC 6522.
// untranslated to no reveal sender's language.
// moreover, translations in unknown languages are confusing, and clients may not display them at all
let text_part = MimePart::new("text/plain", "This is a receipt notification.");
// second body part: machine-readable, always REQUIRED by RFC 6522
//
// We do not include the Final-Recipient field.
// According to <https://datatracker.ietf.org/doc/html/rfc8098#section-3.2.4>
// it MUST be present and be the address on which original message was received,
// but practically it is not going to be used.
let message_text2 = format!(
"Original-Message-ID: <{rfc724_mid}>\r\n\
Disposition: manual-action/MDN-sent-automatically; displayed\r\n",
);
let extension_fields = if additional_msg_ids.is_empty() {
"".to_string()
} else {
"Additional-Message-IDs: ".to_string()
+ &additional_msg_ids
.iter()
.map(|mid| render_rfc724_mid(mid))
.collect::<Vec<String>>()
.join(" ")
+ "\r\n"
};
MimePart::new(
"multipart/report; report-type=disposition-notification",
vec![
text_part,
MimePart::new(
"message/disposition-notification",
message_text2 + &extension_fields,
),
],
)
}
pub(crate) async fn mdn(
context: &Context,
contact_id: ContactId,
rfc724_mid: &str,
additional_rfc724_mids: Vec<String>,
) -> Result<QueuedMail> {
let contact = Contact::get_by_id(context, contact_id).await?;
let timestamp = time();
let addr = contact.get_addr().to_string();
let recipients: Vec<String>;
let encryption_pubkeys = if contact_id == ContactId::SELF {
recipients = Vec::new();
Vec::new()
} else if let Some(key) = contact.public_key(context).await? {
recipients = relay_addrs(&key, &addr);
vec![key]
} else {
warn!(context, "Contact {contact_id} has no key, sending to self.");
// Encryption key for the contact is not available, sending MDN to self only.
recipients = Vec::new();
Vec::new()
};
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
.context("Failed to convert timestamp to DateTime")?
.to_rfc2822();
let headers: Vec<(&'static str, HeaderType<'static>)> = vec![
("Date", mail_builder::headers::raw::Raw::new(date).into()),
(
"To",
mail_builder::headers::address::Address::new_list(vec![Address::new_address(
None::<&'static str>,
contact.get_addr().to_string(),
)])
.into(),
),
// Subject is untranslated to not reveal sender's language.
(
"Subject",
mail_builder::headers::text::Text::new("Receipt Notification".to_string()).into(),
),
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
(
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
),
(
"Chat-Version",
mail_builder::headers::raw::Raw::new("1.0").into(),
),
];
let message = mdn_body(rfc724_mid, additional_rfc724_mids);
let should_attach_pubkey =
MimeFactory::update_mdn_pubkey_attachment(context, &encryption_pubkeys).await?;
let message = add_headers_to_encrypted_part(message, headers);
let raw_message = part_to_bytes(message);
let encryption = QueuedEncryption::Asymmetric { encryption_pubkeys };
let queued_mdn = QueuedMail {
raw_message,
rfc724_mid: create_outgoing_rfc724_mid(),
display_name: String::new(),
encryption,
should_attach_pubkey,
should_sign: true,
should_compress: true,
recipients,
sent_to: Vec::new(),
bcc_self,
};
Ok(queued_mdn)
}
/// Returns the body of a keyupdate message, shaped like a receipt notification.
///
/// The shape is what every core goes by, as a keyupdate carries no marker:
@@ -2443,7 +2369,7 @@ pub(crate) async fn symm_encrypted_securejoin_message(
/// while a plain text body would end up in a contact request.
/// The report deliberately names no original message, see [`crate::keyupdate`].
fn keyupdate_body() -> MimePart<'static> {
// Human-readable first part as RFC 6522 requires, untranslated like in `render_mdn`.
// Human-readable first part as RFC 6522 requires.
let text_part = MimePart::new(
"text/plain",
"This message updates the sender's encryption key and relay list.",
+183 -42
View File
@@ -22,7 +22,7 @@ use crate::key::{load_self_secret_key, secret_key_to_public_key};
use crate::message;
use crate::mimeparser::MimeMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils;
use crate::test_utils::{self, SentMessage};
use crate::test_utils::{TestContext, TestContextManager, get_chat_msg};
use crate::tools::SystemTime;
@@ -281,8 +281,12 @@ async fn test_subject_mdn() {
assert_eq!("Re: Hello, Bob", mf.subject_str(t).await.unwrap());
}
/// Tests that MDN for unencrypted message can be created without throwing an error.
///
/// We do not send unencrypted MDNs, but do not want SMTP loop to get stuck
/// if we somehow request the creation of unencrypted MDN.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mdn_create_encrypted() -> Result<()> {
async fn test_mdn_create_unencrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice.allow_unencrypted().await?;
@@ -297,21 +301,43 @@ async fn test_mdn_create_encrypted() -> Result<()> {
.await?;
bob.set_config_bool(Config::MdnsEnabled, true).await?;
// MDN for unencrypted message is not encrypted.
// MDN for unencrypted message.
// Should not happen, but should also not throw an error.
let mut msg = Message::new(Viewtype::Text);
let chat_alice = alice.create_email_chat(&bob).await.id;
let sent = alice.send_msg(chat_alice, &mut msg).await;
let rcvd = bob.recv_msg(&sent).await;
message::markseen_msgs(&bob, vec![rcvd.id]).await?;
let mimefactory =
MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
assert!(!mimefactory.will_be_encrypted());
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
let queued_mdn = mdn(&bob, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
// Only sending MDN to self (if BCC-self is enabled) because address-contact recipient has no key.
assert!(queued_mdn.recipients.is_empty());
// MDNs are always encrypted, even if requested for unencrypted message.
assert!(queued_mdn.encryption.is_encrypted());
bob.assert_warn("has no key, sending to self").await;
Ok(())
}
/// Tests that MDNs sent in reply to encrypted messages are encrypted
/// and MDNs for unencrypted messages are not created.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mdn_create_encrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice
.set_config(Config::Displayname, Some("Alice Exampleorg"))
.await?;
let bob = tcm.bob().await;
bob.set_config(Config::Displayname, Some("Bob Examplenet"))
.await?;
bob.set_config(Config::Selfstatus, Some("Bob Examplenet"))
.await?;
bob.set_config_bool(Config::MdnsEnabled, true).await?;
assert!(!rendered_msg.message.contains("Bob Examplenet"));
assert!(!rendered_msg.message.contains("Alice Exampleorg"));
let bob_alice_contact = bob.add_or_lookup_contact(&alice).await;
assert_eq!(bob_alice_contact.get_authname(), "Alice Exampleorg");
@@ -319,10 +345,9 @@ async fn test_mdn_create_encrypted() -> Result<()> {
let rcvd = tcm.send_recv(&alice, &bob, "Heyho").await;
message::markseen_msgs(&bob, vec![rcvd.id]).await?;
let mimefactory = MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid, vec![]).await?;
assert!(mimefactory.will_be_encrypted());
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
let queued_mdn = mdn(&bob, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
assert!(queued_mdn.encryption.is_encrypted());
let rendered_msg = render_queued_mail_with_context(queued_mdn, &bob).await?;
assert!(!rendered_msg.message.contains("Bob Examplenet"));
assert!(!rendered_msg.message.contains("Alice Exampleorg"));
@@ -349,8 +374,8 @@ async fn test_mdn_sent_to_all_relays() -> Result<()> {
)?;
import_public_key(alice, &bob_public_key).await?;
let mimefactory = MimeFactory::from_mdn(alice, rcvd.from_id, rcvd.rfc724_mid, vec![]).await?;
let mut recipients = mimefactory.recipients();
let queued_mdn = mdn(alice, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
let mut recipients = queued_mdn.recipients;
recipients.sort();
assert_eq!(recipients, vec!["bob@example.net", "bob@relay2.example"]);
@@ -364,9 +389,8 @@ async fn test_mdn_autocrypt_throttle() -> Result<()> {
alice: &TestContext,
rcvd: &Message,
) -> Result<bool> {
let mf = MimeFactory::from_mdn(bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
let addr = bob.get_primary_self_addr().await?;
let rendered_msg = mf.render(bob, &addr).await?;
let queued_mdn = mdn(bob, rcvd.from_id, &rcvd.rfc724_mid, vec![]).await?;
let rendered_msg = render_queued_mail_with_context(queued_mdn, bob).await?;
let mime = MimeMessage::from_bytes(alice, rendered_msg.message.as_bytes()).await?;
Ok(mime.autocrypt_fingerprint.is_some())
}
@@ -647,8 +671,7 @@ async fn test_render_reply() {
let recipients = mimefactory.recipients();
assert_eq!(recipients, vec!["charlie@example.net"]);
let addr = t.get_primary_self_addr().await.unwrap();
let rendered_msg = mimefactory.render(t, &addr).await.unwrap();
let rendered_msg = mimefactory.render(t).await.unwrap();
let mail = mailparse::parse_mail(rendered_msg.message.as_bytes()).unwrap();
assert_eq!(
@@ -795,7 +818,7 @@ async fn test_protected_headers_directive() -> Result<()> {
// Long messages are truncated and MimeMessage::decoded_data is set for them. We need
// decoded_data to check presence of the necessary headers.
msg.set_text("a".repeat(constants::DC_DESIRED_TEXT_LEN + 1));
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)?;
msg.set_file_from_bytes(&bob, "foo.bar", b"content", None)?;
let sent = bob.send_msg(chat, &mut msg).await;
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
@@ -1022,7 +1045,7 @@ END:VCARD";
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_outer_headers() -> Result<()> {
async fn test_render_outer_headers_of_encrypted_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
@@ -1030,26 +1053,9 @@ async fn test_render_outer_headers() -> Result<()> {
let chat_id = alice.create_chat_id(bob).await;
let sent = alice.send_text(chat_id, "Hello!").await;
let (unencrypted, _encrypted) = sent
.payload()
.split_once("-----BEGIN PGP MESSAGE-----")
.unwrap();
let payload = normalized_payload(sent).await;
// Normalize the parts of the message that vary between runs
// (MIME boundary, Date, Message-ID)
let boundary = unencrypted
.split_once("boundary=\"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(b, _)| b)
.unwrap_or_default();
let unencrypted = unencrypted.replace(boundary, "BOUNDARY");
let rfc724_mid = sent.load_from_db().await.rfc724_mid;
let unencrypted = unencrypted.replace(&rfc724_mid, "MESSAGE_ID@localhost");
let unencrypted = regex!(r"Date:[^\r\n]*")
.replace(&unencrypted, "Date: DATE")
.to_string();
let (unencrypted, _encrypted) = payload.split_once("-----BEGIN PGP MESSAGE-----").unwrap();
let expected = r#"From: <alice@example.org>
Date: DATE
@@ -1085,3 +1091,138 @@ expected (debug print): {expected:?}"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_unencrypted_msg_basic() -> Result<()> {
let alice = &TestContext::new_alice().await;
alice.allow_unencrypted().await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.net")
.await;
let sent = alice.send_text(chat.id, "Hello!").await;
let unencrypted = normalized_payload(sent).await;
let expected = r#"From: <alice@example.org>
Message-ID: <MESSAGE_ID@localhost>
MIME-Version: 1.0
Autocrypt: addr=alice@example.org; prefer-encrypt=mutual; keydata=mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5
DCtAQfFggAZgUCXlh13RYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDAhsDAh4JBAsJCAcFFQgJCgsDFgIB
AycJAgIZASwUgAAAAAASABFyZWxheXNAY2hhdG1haWwuYXRhbGljZUBleGFtcGxlLm9yZwAAb1QA/0
HbvPN3/Vn02Gk1dcQMEcyGyETld9dSsRo8uwHAyW35AQCrFJjAQFLTud7XK61uYt9BC/QHipCfIGbq
X1FjMbTUC80TPGFsaWNlQGV4YW1wbGUub3JnPsKRBBMWCAA5BQJeWHXdFiEELm+iyyO1MtcoY0tYZL
CPYantlEMCGwMCHgkECwkIBwUVCAkKCwMWAgEDJwkCAhkBAAoJEGSwj2Gp7ZRD1m4A/iOifEzIOiP8
wW0O8I/sg69gQtG8Czn4MsVV6Ea1EyIqAP4uByHaUJdy8MSQPfv/Usr09KsidNgy2Jh37yg82fKUBr
g4BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp01JrRe6Xqy22HQMBCAeI
eAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsMAAoJEGSwj2Gp7ZRDLo8BAObE8G
nsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIyVfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp
3jsMAg==
Content-Type: text/plain; charset="utf-8"
Date: DATE
To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
Content-Transfer-Encoding: 7bit
Hello!"#
.replace("\n", "\r\n");
assert_eq!(
unencrypted, expected,
"---------------- Actual: ----------------
{unencrypted}
-----------------------------------------
actual (debug print): {unencrypted:?}
expected (debug print): {expected:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_unencrypted_msg_with_attachment() -> Result<()> {
let alice = &TestContext::new_alice().await;
alice.allow_unencrypted().await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.net")
.await;
let mut msg = Message::new(Viewtype::File);
msg.set_text("Hello!".to_string());
msg.set_file_from_bytes(alice, "foo.bar", b"content", None)?;
let sent = alice.send_msg(chat.id, &mut msg).await;
let unencrypted = normalized_payload(sent).await;
let expected = r#"From: <alice@example.org>
Message-ID: <MESSAGE_ID@localhost>
MIME-Version: 1.0
Autocrypt: addr=alice@example.org; prefer-encrypt=mutual; keydata=mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5
DCtAQfFggAZgUCXlh13RYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDAhsDAh4JBAsJCAcFFQgJCgsDFgIB
AycJAgIZASwUgAAAAAASABFyZWxheXNAY2hhdG1haWwuYXRhbGljZUBleGFtcGxlLm9yZwAAb1QA/0
HbvPN3/Vn02Gk1dcQMEcyGyETld9dSsRo8uwHAyW35AQCrFJjAQFLTud7XK61uYt9BC/QHipCfIGbq
X1FjMbTUC80TPGFsaWNlQGV4YW1wbGUub3JnPsKRBBMWCAA5BQJeWHXdFiEELm+iyyO1MtcoY0tYZL
CPYantlEMCGwMCHgkECwkIBwUVCAkKCwMWAgEDJwkCAhkBAAoJEGSwj2Gp7ZRD1m4A/iOifEzIOiP8
wW0O8I/sg69gQtG8Czn4MsVV6Ea1EyIqAP4uByHaUJdy8MSQPfv/Usr09KsidNgy2Jh37yg82fKUBr
g4BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp01JrRe6Xqy22HQMBCAeI
eAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsMAAoJEGSwj2Gp7ZRDLo8BAObE8G
nsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIyVfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp
3jsMAg==
Content-Type: multipart/mixed;
boundary="BOUNDARY"
Date: DATE
To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
--BOUNDARY
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
Hello!
--BOUNDARY
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="foo.bar"
Content-Transfer-Encoding: base64
Y29udGVudA==
--BOUNDARY--
"#
.replace("\n", "\r\n");
assert_eq!(
unencrypted, expected,
"---------------- Actual: ----------------
{unencrypted}
-----------------------------------------
actual (debug print): {unencrypted:?}
expected (debug print): {expected:?}"
);
Ok(())
}
/// Normalize the parts of the message that vary between runs
/// (MIME boundary, Date, Message-ID)
async fn normalized_payload(sent: SentMessage<'_>) -> String {
let rfc724_mid = sent.load_from_db().await.rfc724_mid;
let mut payload = sent.payload;
if let Some(boundary) = payload
.split_once("boundary=\"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(b, _)| b)
{
payload = payload.replace(boundary, "BOUNDARY");
}
payload = payload.replace(&rfc724_mid, "MESSAGE_ID@localhost");
payload = regex!(r"Date:[^\r\n]*")
.replace(&payload, "Date: DATE")
.to_string();
payload
}
+18 -22
View File
@@ -294,11 +294,7 @@ impl MimeMessage {
&mut wants_mdn,
&mail,
);
headers_removed.extend(
headers
.extract_if(|k, _v| is_hidden(k))
.map(|(k, _v)| k.to_string()),
);
headers_removed.extend(headers.extract_if(|k, _v| is_hidden(k)).map(|(k, _v)| k));
// Parse hidden headers.
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
@@ -935,9 +931,10 @@ impl MimeMessage {
// See if an MDN is requested from the other side
if self.decryption_error.is_none()
&& (!self.parts.is_empty() || matches!(&self.pre_message, PreMessageMode::Pre { .. }))
&& self.wants_mdn
&& self.incoming
&& let Some(part) = self.parts.last_mut()
&& self.wants_mdn
&& self.was_encrypted() // Do not send MDNs for unencrypted messages.
&& self.incoming
&& let Some(part) = self.parts.last_mut()
{
part.param.set_int(Param::WantsMdn, 1);
}
@@ -951,7 +948,7 @@ impl MimeMessage {
typ: Viewtype::Text,
..Default::default()
};
if self.wants_mdn && self.incoming {
if self.wants_mdn && self.was_encrypted() && self.incoming {
part.param.set_int(Param::WantsMdn, 1);
}
if let Some(ref subject) = self.get_subject()
@@ -1751,7 +1748,7 @@ impl MimeMessage {
headers_removed.extend(
headers
.extract_if(|k, _v| has_header_protection || is_protected(k))
.map(|(k, _v)| k.to_string()),
.map(|(k, _v)| k),
);
if has_header_protection {
@@ -2540,19 +2537,18 @@ async fn handle_ndn(
for msg_id in msg_ids {
let mut message = Message::load_from_db(context, msg_id).await?;
let chat = Chat::load_from_db(context, message.chat_id).await?;
if chat.typ == constants::Chattype::OutBroadcast {
continue;
if chat.typ == constants::Chattype::Single {
let aggregated_error = message
.error
.as_ref()
.map(|err| format!("{err}\n\n{err_msg}"));
set_msg_failed(
context,
&mut message,
aggregated_error.as_ref().unwrap_or(err_msg),
)
.await?;
}
let aggregated_error = message
.error
.as_ref()
.map(|err| format!("{err}\n\n{err_msg}"));
set_msg_failed(
context,
&mut message,
aggregated_error.as_ref().unwrap_or(err_msg),
)
.await?;
}
Ok(())
+4 -2
View File
@@ -410,7 +410,8 @@ impl ProxyConfig {
};
let tcp_stream =
crate::net::connect_tcp(context, &hostname, http_config.port, load_cache)
.await?;
.await
.context("Failed to connect to HTTP proxy")?;
let auth = if let Some((username, password)) = &http_config.user_password {
Some((username.as_str(), password.as_str()))
} else {
@@ -429,7 +430,8 @@ impl ProxyConfig {
let tcp_stream =
crate::net::connect_tcp(context, &hostname, https_config.port, load_cache)
.await?;
.await
.context("Failed to connect to HTTPS proxy")?;
let use_sni = true;
let tls_stream = wrap_rustls(
&hostname,
+1 -1
View File
@@ -46,7 +46,7 @@ use crate::mimeparser::SystemMessage;
/// The length of an ed25519 `PublicKey`, in bytes.
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
const PUBLIC_KEY_STUB: &[u8] = b"static_string";
/// Store Iroh peer channels for the context.
#[derive(Debug)]
+10 -10
View File
@@ -86,14 +86,14 @@ async fn test_can_communicate() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
if data == b"alice -> bob" {
break;
} else {
panic!(
@@ -107,14 +107,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
if data == b"bob -> alice" {
break;
} else {
panic!(
@@ -149,14 +149,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice 2".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice 2".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice 2".as_bytes() {
if data == b"bob -> alice 2" {
break;
} else {
panic!(
@@ -314,14 +314,14 @@ async fn test_can_reconnect() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
if data == b"alice -> bob" {
break;
} else {
panic!(
@@ -373,14 +373,14 @@ async fn test_can_reconnect() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
if data == b"bob -> alice" {
break;
} else {
panic!(
+1 -1
View File
@@ -401,7 +401,7 @@ pub fn merge_openpgp_certificates(
// such as Alice's key in `test-data/key/alice-secret.asc`.
let best_user: Option<SignedUser> = old_users
.into_iter()
.chain(new_users.clone())
.chain(new_users)
.filter_map(|SignedUser { id, signatures }| {
// Select the best signature for each User ID.
// If User ID has no valid signatures, it is filtered out.
+1 -1
View File
@@ -384,7 +384,7 @@ fn test_merge_openpgp_certificates() {
// Cannot merge certificates with different primary key.
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
assert!(merge_openpgp_certificates(bob, alice).is_err());
}
/// Test PQC support.
+6 -223
View File
@@ -13,6 +13,7 @@ use anyhow::{Result, ensure};
use crate::chat::{ChatId, send_msg};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::log::warn;
use crate::message::{Message, MessageState, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
@@ -78,7 +79,11 @@ async fn update_pinned_state_in_db(
(new_pinned_state, msg.id),
)
.await?;
context.emit_msgs_changed(msg.chat_id, msg.id);
context.emit_event(EventType::PinnedMessagesChanged {
chat_id: msg.chat_id,
});
Ok(())
}
@@ -140,226 +145,4 @@ pub(crate) async fn handle_pinned_state_from_wire(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::{ChatItem, add_info_msg, create_broadcast, get_chat_msgs};
use crate::config::Config;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{TestContextManager, sync};
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pinned_messages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await; // Alice's second device
let bob = &tcm.bob().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
// Alice creates all chat types upfront, with Bob as member if possible
let single_chat_id = alice.create_chat(bob).await.id;
let group_chat_id = alice.create_group_with_members("Group", &[bob]).await;
let broadcast_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(broadcast_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let self_chat_id = alice.get_self_chat().await.id;
sync(alice, alice2).await;
for alice_chat_id in [
single_chat_id,
group_chat_id,
broadcast_chat_id,
self_chat_id,
] {
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert!(pinned.is_empty());
// Alice sends message "Foo" and pins it
let sent1 = alice.send_text(alice_chat_id, "Foo").await;
let msg1 = sent1.load_from_db().await;
assert!(!msg1.is_pinned());
set_pinned_state(alice, msg1.id, true).await?;
let sent2 = alice.pop_sent_msg().await;
assert!(sent1.load_from_db().await.is_pinned());
let info_msg = sent2.load_from_db().await;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned);
assert!(info_msg.get_info_contact_id(alice).await?.is_none()); // contact not needed, tapping shall jump to message
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
// Pinning an info message does not work
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;
assert!(!sent1.load_from_db().await.is_pinned());
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
if alice_chat_id != self_chat_id {
// Bob receives message "Foo"
let msg1 = bob.recv_msg(&sent1).await;
assert!(!msg1.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert!(pinned.is_empty());
// Bob receives info message to pin "Foo"
bob.recv_msg(&sent2).await;
assert!(Message::load_from_db(bob, msg1.id).await?.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
let info_msg =
Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
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;
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");
}
// Alice's second device receives all four messages and ends up in the same state
let msg1 = alice2.recv_msg(&sent1).await;
alice2.recv_msg(&sent2).await;
assert!(Message::load_from_db(alice2, msg1.id).await?.is_pinned());
alice2.recv_msg(&sent3).await;
alice2.recv_msg_trash(&sent4).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");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_pinned_messages_order() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let boilerplate_msg_count = get_chat_msgs(alice, chat_id).await?.len();
// create three messages, sent1 and sent2 have different timestamp, sent2 and sent3 may differ by ID only
let sent1 = alice.send_text(chat_id, "1").await;
tokio::time::sleep(Duration::from_millis(1100)).await;
let sent2 = alice.send_text(chat_id, "2").await;
let sent3 = alice.send_text(chat_id, "3").await;
// get_chat_msgs() start with the oldest message
let chat_msgs = get_chat_msgs(alice, chat_id).await?;
let msg_ids: Vec<_> = chat_msgs
.into_iter()
.filter_map(|item| match item {
ChatItem::Message { msg_id } => Some(msg_id),
ChatItem::DayMarker { .. } => None,
})
.collect();
assert_eq!(
&msg_ids[boilerplate_msg_count..],
&[
sent1.sender_msg_id,
sent2.sender_msg_id,
sent3.sender_msg_id
]
);
// get_pinned_messages() has the same order, also starting with the oldest message
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
// order of pinning does not affect the order of pinned messages.
// this is to keep scrolling direction of chat bubbles and pinned banner scrollbar in sync,
// and not jumping wildly around.
// this is also what most other messengers are doing.
set_pinned_state(alice, sent1.sender_msg_id, false).await?;
set_pinned_state(alice, sent2.sender_msg_id, false).await?;
set_pinned_state(alice, sent3.sender_msg_id, false).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 0);
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handle_pinned_state_from_wire() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let sent1 = alice.send_text(chat_id, "pinnable").await;
let msg1 = sent1.load_from_db().await;
assert!(is_pinnable(&msg1));
assert!(
handle_pinned_state_from_wire(alice, &msg1, true)
.await
.is_ok()
);
// For not-pinnable messages, handle_pinned_state_from_wire() logs a warning and returns "ok".
// otherwise if there is an incompatibility in which messages are treated as "pinnable",
// this error will bubble up and user will get a device message saying "please report a bug".
let msg2_id = add_info_msg(alice, chat_id, "not pinnable").await?;
let msg2 = Message::load_from_db(alice, msg2_id).await?;
assert!(!is_pinnable(&msg2));
assert!(
handle_pinned_state_from_wire(alice, &msg2, true)
.await
.is_ok()
);
alice.assert_warn("Message is not pinnable").await;
Ok(())
}
}
mod pinned_messages_tests;
@@ -0,0 +1,309 @@
use super::*;
use crate::chat::{ChatItem, add_info_msg, create_broadcast, get_chat_msgs};
use crate::config::Config;
use crate::ephemeral;
use crate::message;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{TestContext, TestContextManager, sync};
use crate::tools::{SystemTime, time};
use std::time::Duration;
/// Waits for a PinnedMessagesChanged for a given `chat_id`.
///
/// Panics if event arrives for the wrong `chat_id`.
async fn expect_pinned_message_event(context: &TestContext, chat_id: ChatId) {
let EventType::PinnedMessagesChanged {
chat_id: event_chat_id,
} = context
.evtracker
.get_matching(|evt| matches!(evt, EventType::PinnedMessagesChanged { .. }))
.await
else {
unreachable!();
};
assert_eq!(event_chat_id, chat_id);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pinned_messages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await; // Alice's second device
let bob = &tcm.bob().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
// Alice creates all chat types upfront, with Bob as member if possible
let single_chat_id = alice.create_chat(bob).await.id;
let group_chat_id = alice.create_group_with_members("Group", &[bob]).await;
let broadcast_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(broadcast_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let self_chat_id = alice.get_self_chat().await.id;
sync(alice, alice2).await;
for alice_chat_id in [
single_chat_id,
group_chat_id,
broadcast_chat_id,
self_chat_id,
] {
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert!(pinned.is_empty());
// Alice sends message "Foo" and pins it
let sent1 = alice.send_text(alice_chat_id, "Foo").await;
let msg1 = sent1.load_from_db().await;
assert!(!msg1.is_pinned());
set_pinned_state(alice, msg1.id, true).await?;
let sent2 = alice.pop_sent_msg().await;
expect_pinned_message_event(alice, msg1.chat_id).await;
assert!(sent1.load_from_db().await.is_pinned());
let info_msg = sent2.load_from_db().await;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned);
assert!(info_msg.get_info_contact_id(alice).await?.is_none()); // contact not needed, tapping shall jump to message
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
// Pinning an info message does not work
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;
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
if alice_chat_id != self_chat_id {
// Bob receives message "Foo"
let msg1 = bob.recv_msg(&sent1).await;
assert!(!msg1.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert!(pinned.is_empty());
// Bob receives info message to pin "Foo"
bob.recv_msg(&sent2).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_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
let info_msg =
Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
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;
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");
}
// Alice's second device receives all four messages and ends up in the same state
let msg1 = alice2.recv_msg(&sent1).await;
alice2.recv_msg(&sent2).await;
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;
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");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_pinned_messages_order() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let boilerplate_msg_count = get_chat_msgs(alice, chat_id).await?.len();
// create three messages, sent1 and sent2 have different timestamp, sent2 and sent3 may differ by ID only
let sent1 = alice.send_text(chat_id, "1").await;
tokio::time::sleep(Duration::from_millis(1100)).await;
let sent2 = alice.send_text(chat_id, "2").await;
let sent3 = alice.send_text(chat_id, "3").await;
// get_chat_msgs() start with the oldest message
let chat_msgs = get_chat_msgs(alice, chat_id).await?;
let msg_ids: Vec<_> = chat_msgs
.into_iter()
.filter_map(|item| match item {
ChatItem::Message { msg_id } => Some(msg_id),
ChatItem::DayMarker { .. } => None,
})
.collect();
assert_eq!(
&msg_ids[boilerplate_msg_count..],
&[
sent1.sender_msg_id,
sent2.sender_msg_id,
sent3.sender_msg_id
]
);
// get_pinned_messages() has the same order, also starting with the oldest message
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
// order of pinning does not affect the order of pinned messages.
// this is to keep scrolling direction of chat bubbles and pinned banner scrollbar in sync,
// and not jumping wildly around.
// this is also what most other messengers are doing.
set_pinned_state(alice, sent1.sender_msg_id, false).await?;
set_pinned_state(alice, sent2.sender_msg_id, false).await?;
set_pinned_state(alice, sent3.sender_msg_id, false).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 0);
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handle_pinned_state_from_wire() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let sent1 = alice.send_text(chat_id, "pinnable").await;
let msg1 = sent1.load_from_db().await;
assert!(is_pinnable(&msg1));
assert!(
handle_pinned_state_from_wire(alice, &msg1, true)
.await
.is_ok()
);
// For not-pinnable messages, handle_pinned_state_from_wire() logs a warning and returns "ok".
// otherwise if there is an incompatibility in which messages are treated as "pinnable",
// this error will bubble up and user will get a device message saying "please report a bug".
let msg2_id = add_info_msg(alice, chat_id, "not pinnable").await?;
let msg2 = Message::load_from_db(alice, msg2_id).await?;
assert!(!is_pinnable(&msg2));
assert!(
handle_pinned_state_from_wire(alice, &msg2, true)
.await
.is_ok()
);
alice.assert_warn("Message is not pinnable").await;
Ok(())
}
/// Tests that disappearing pinned message expires and emits `PinnedMessagesChanged` event.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_pinned_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_chat_id(bob).await;
// Alice sends ephemeral timer in single chat with Bob.
alice_chat_id
.set_ephemeral_timer(alice, ephemeral::Timer::from_u32(60))
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
// Alice sends "Hello!" message to Bob.
let bob_msg = tcm.send_recv_accept(alice, bob, "Hello!").await;
let bob_chat_id = bob_msg.chat_id;
// Bob reads the message, so the timer starts.
message::markseen_msgs(bob, vec![bob_msg.id]).await?;
// Bob pins "Hello!" message received from Alice.
set_pinned_state(bob, bob_msg.id, true).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert_eq!(pinned.len(), 1);
// Wait until the message expires.
SystemTime::shift(Duration::from_secs(100));
ephemeral::delete_expired_messages(bob, time()).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert!(pinned.is_empty());
Ok(())
}
/// Tests that `PinnedMessagesChanged` event is emitted when pinned message is deleted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_pinned_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_msg = tcm.send_recv_accept(alice, bob, "Hello!").await;
let bob_chat_id = bob_msg.chat_id;
// Bob pins "Hello!" message received from Alice.
set_pinned_state(bob, bob_msg.id, true).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert_eq!(pinned.len(), 1);
message::delete_msgs(bob, &[bob_msg.id]).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert!(pinned.is_empty());
Ok(())
}
+5 -9
View File
@@ -1,6 +1,6 @@
//! Handle plain text together with some attributes.
use std::sync::LazyLock;
use regex::regex;
use crate::simplify::remove_message_footer;
@@ -25,12 +25,8 @@ impl PlainText {
/// Convert plain text to HTML.
/// The function handles quotes, links, fixed and floating text paragraphs.
pub fn to_html(&self) -> String {
static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());
static LINKIFY_URL_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)").unwrap()
});
let linkify_mail_re = regex!(r"\b([\w.\-+]+@[\w.\-]+)\b");
let linkify_url_re = regex!(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)");
let lines: Vec<&str> = self.text.lines().collect();
let (lines, _footer) = remove_message_footer(&lines);
@@ -52,12 +48,12 @@ impl PlainText {
// to avoid double encoding, we escape our html-entities by \r that must not be used in the string elsewhere.
let line = line.to_string().replace('\r', "");
let mut line = LINKIFY_MAIL_RE
let mut line = linkify_mail_re
.replace_all(&line, "\rLTa href=\rQUOTmailto:$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
line = LINKIFY_URL_RE
line = linkify_url_re
.replace_all(&line, "\rLTa href=\rQUOT$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
+25 -40
View File
@@ -2,17 +2,18 @@
mod dclogin_scheme;
use std::collections::BTreeMap;
use std::sync::LazyLock;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
pub use dclogin_scheme::LoginOptions;
pub(crate) use dclogin_scheme::login_param_from_login_qr;
use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
use regex::regex;
use serde::Deserialize;
use crate::autorelay::login_param_from_host;
use crate::config::Config;
use crate::configure::MAX_RELAYS;
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::key::Fingerprint;
@@ -133,18 +134,6 @@ pub enum Qr {
contact_id: ContactId,
},
/// Scanned fingerprint does not match the last seen fingerprint.
FprMismatch {
/// Contact ID.
contact_id: Option<ContactId>,
},
/// The scanned QR code contains a fingerprint but no e-mail address.
FprWithoutAddr {
/// Key fingerprint.
fingerprint: String,
},
/// Ask the user if they want to create an account on the given domain.
Account {
/// Server domain name.
@@ -500,7 +489,8 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
addrs.push(normalize_address(primary_addr)?);
};
if let Some(secondary_addrs_raw) = param.get("r") {
for secondary_address in secondary_addrs_raw.split(',') {
let max_secondary = MAX_RELAYS.saturating_sub(addrs.len());
for secondary_address in secondary_addrs_raw.split(',').take(max_secondary) {
addrs.push(normalize_address(secondary_address)?)
}
}
@@ -651,24 +641,21 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
is_v3,
})
}
} else if let Some(addr) = addrs.first() {
let fingerprint = fingerprint.hex();
let (contact_id, _) =
Contact::add_or_lookup_ext(context, "", addr, &fingerprint, Origin::UnhandledQrScan)
.await?;
let contact = Contact::get_by_id(context, contact_id).await?;
if contact.public_key(context).await?.is_some() {
Ok(Qr::FprOk { contact_id })
} else {
Ok(Qr::FprMismatch {
contact_id: Some(contact_id),
})
}
} else {
Ok(Qr::FprWithoutAddr {
fingerprint: fingerprint.human_readable(),
})
let fingerprint = fingerprint.hex();
let contact_id: Option<ContactId> = context
.sql
.query_get_value(
"SELECT id FROM contacts WHERE fingerprint=?",
(fingerprint,),
)
.await?;
let Some(contact_id) = contact_id else {
bail!("Contact matching the fingerprint is not found");
};
Ok(Qr::FprOk { contact_id })
}
}
@@ -777,7 +764,7 @@ fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
let addr = server_config.addr();
let host = addr.host().to_string();
let host = addr.host();
let port = addr.port();
Ok(Qr::Proxy {
url: qr.to_string(),
@@ -1084,16 +1071,11 @@ async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
Qr::from_address(context, name, &addr, None).await
}
static VCARD_NAME_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
/// Extract address for the vcard scheme.
///
/// Scheme: `VCARD:BEGIN\nN:last name;first name;...;\nEMAIL;<type>:addr...;`
async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
let name = VCARD_NAME_RE
let name = regex!(r"(?m)^N:([^;]*);([^;\n]*)")
.captures(qr)
.and_then(|caps| {
let last_name = caps.get(1)?.as_str().trim();
@@ -1103,7 +1085,10 @@ async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
})
.unwrap_or_default();
let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
let addr = if let Some(cap) = regex!(r"(?m)^EMAIL([^:\n]*):([^;\n]*)")
.captures(qr)
.and_then(|caps| caps.get(2))
{
normalize_address(cap.as_str().trim())?
} else {
bail!("Bad e-mail address");
@@ -1137,7 +1122,7 @@ fn normalize_address(addr: &str) -> Result<String> {
ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
Ok(new_addr.to_string())
Ok(new_addr)
}
#[cfg(test)]
+1 -1
View File
@@ -120,7 +120,7 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
};
Ok(Qr::Login {
address: addr.to_owned(),
address: addr,
options,
})
} else {
+52 -38
View File
@@ -305,12 +305,10 @@ async fn test_decode_openpgp_invalid_token() -> Result<()> {
let ctx = TestContext::new_alice().await;
// Token cannot contain "/"
let qr = check_qr(
assert!(check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL/cxRL"
).await?;
assert!(matches!(qr, Qr::FprMismatch { .. }));
).await.is_err());
Ok(())
}
@@ -361,6 +359,23 @@ async fn test_decode_openpgp_secure_join() -> Result<()> {
bail!("Wrong QR code type");
}
// A bad invite code must not be able to steer us onto arbitrarily many relays.
let relays: Vec<String> = (0..20).map(|i| format!("cli%40r{i}.example.org")).collect();
let qr = check_qr(
&ctx.ctx,
&format!(
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&r={}&i=TbnwJ6lSvD5&s=0ejvbdFSQxB",
relays.join(",")
),
)
.await?;
if let Qr::AskVerifyContact { addrs, .. } = qr {
assert_eq!(addrs.len(), MAX_RELAYS);
} else {
bail!("Wrong QR code type");
}
Ok(())
}
@@ -373,16 +388,17 @@ async fn test_decode_openpgp_fingerprint() -> Result<()> {
let alice_contact = bob.add_or_lookup_contact(alice).await;
let alice_contact_id = alice_contact.id;
let qr = check_qr(
bob,
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org",
)
.await?;
if let Qr::FprMismatch { contact_id, .. } = qr {
assert_ne!(contact_id.unwrap(), alice_contact_id);
} else {
bail!("Wrong QR code type");
}
// OPENPGP4FPR may have an address,
// but it is not used anymore since key contacts are introduced.
// We lookup the contact only by fingerprint and ignore the address.
assert!(
check_qr(
bob,
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org",
)
.await
.is_err()
);
let qr = check_qr(
bob,
@@ -398,46 +414,44 @@ async fn test_decode_openpgp_fingerprint() -> Result<()> {
bail!("Wrong QR code type");
}
assert!(matches!(
assert!(
check_qr(
bob,
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=bob@example.org",
)
.await?,
Qr::FprMismatch { .. }
));
.await
.is_err()
);
Ok(())
}
/// Tests OPENPGP4FPR QR codes without an email address.
///
/// Email address in OPENPGP4FPR QR codes was an extension
/// used before switch to identifying contacts by fingerprint.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_without_addr() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890",
)
.await?;
assert_eq!(
qr,
Qr::FprWithoutAddr {
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
}
assert!(
check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890",
)
.await
.is_err()
);
// Test it again with lowercased "openpgp4fpr:" uri scheme
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:1234567890123456789012345678901234567890",
)
.await?;
assert_eq!(
qr,
Qr::FprWithoutAddr {
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
}
assert!(
check_qr(
&ctx.ctx,
"openpgp4fpr:1234567890123456789012345678901234567890",
)
.await
.is_err()
);
let res = check_qr(&ctx.ctx, "OPENPGP4FPR:12345678901234567890").await;
+1 -1
View File
@@ -165,7 +165,7 @@ async fn set_msg_id_reaction(
.await?;
if chat
.param
.update_timestamp(Param::LastReactionTimestamp, timestamp)?
.update_timestamp(Param::LastReactionTimestamp, timestamp)
{
chat.param
.set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32()));
+23 -17
View File
@@ -483,7 +483,7 @@ pub(crate) async fn receive_imf_inner(
}
let trash = || async {
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
let msg_ids = vec![insert_tombstone(context, rfc724_mid, false).await?];
Ok(Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::Undefined,
@@ -668,14 +668,15 @@ pub(crate) async fn receive_imf_inner(
match res {
securejoin::HandshakeMessage::Done | securejoin::HandshakeMessage::Ignore => {
let msg_id = insert_tombstone(context, rfc724_mid).await?;
let needs_delete_job = res == securejoin::HandshakeMessage::Done;
let msg_id = insert_tombstone(context, rfc724_mid, needs_delete_job).await?;
received_msg = Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::InSeen,
hidden: false,
sort_timestamp: mime_parser.timestamp_sent,
msg_ids: vec![msg_id],
needs_delete_job: res == securejoin::HandshakeMessage::Done,
needs_delete_job,
});
}
securejoin::HandshakeMessage::Propagate => {
@@ -2107,7 +2108,8 @@ async fn add_parts(
let hidden = part.is_reaction;
if part.is_reaction {
let reaction_str = simplify::remove_footers(part.msg.as_str());
let is_incoming_fresh = mime_parser.incoming && !seen;
let is_incoming_fresh =
mime_parser.incoming && !seen && chat_id_blocked == Blocked::Not;
set_msg_reaction(
context,
mime_in_reply_to,
@@ -2310,7 +2312,7 @@ INSERT INTO msgs
// This way, `LastSubject` actually refers to the most recent message _shown_ in the chat.
if chat
.param
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)?
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)
{
// write the last subject even if empty -
// otherwise a reply may get an outdated subject.
@@ -2391,6 +2393,7 @@ async fn handle_edit_delete(
}
let mut modified_chat_ids = BTreeSet::new();
let mut pinned_messages_changed_chat_ids = BTreeSet::new();
let mut msg_ids = Vec::new();
let rfc724_mid_vec: Vec<&str> = rfc724_mid_list.split_whitespace().collect();
@@ -2399,7 +2402,7 @@ 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).await?;
insert_tombstone(context, rfc724_mid, false).await?;
continue;
};
@@ -2415,8 +2418,17 @@ async fn handle_edit_delete(
message::delete_msg_locally(context, &msg).await?;
msg_ids.push(msg.id);
modified_chat_ids.insert(msg.chat_id);
if msg.is_pinned() {
pinned_messages_changed_chat_ids.insert(msg.chat_id);
}
}
message::delete_msgs_locally_done(context, &msg_ids, modified_chat_ids).await?;
message::delete_msgs_locally_done(
context,
&msg_ids,
modified_chat_ids,
pinned_messages_changed_chat_ids,
)
.await?;
}
Ok(())
}
@@ -3403,7 +3415,7 @@ async fn apply_chat_name_avatar_and_description_changes(
&& is_from_in_chat
&& chat
.param
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)?
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)
{
info!(context, "Group-avatar change for {}.", chat.id);
match avatar_action {
@@ -3732,10 +3744,9 @@ async fn apply_out_broadcast_changes(
if from_id == ContactId::SELF {
let added_id = lookup_key_contact_by_fingerprint(context, added_fpr).await?;
if let Some(added_id) = added_id {
if chat::is_contact_in_chat(context, chat.id, added_id).await? {
info!(context, "No-op broadcast addition (TRASH)");
better_msg.get_or_insert("".to_string());
} else {
info!(context, "Broadcast addition (TRASH)");
better_msg.get_or_insert("".to_string());
if !chat::is_contact_in_chat(context, chat.id, added_id).await? {
chat::add_to_chat_contacts_table(
context,
mime_parser.timestamp_sent,
@@ -3743,11 +3754,6 @@ async fn apply_out_broadcast_changes(
&[added_id],
)
.await?;
let msg =
stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED)
.await;
better_msg.get_or_insert(msg);
added_removed_id = Some(added_id);
send_event_chat_modified = true;
}
} else {
+36 -33
View File
@@ -416,7 +416,9 @@ async fn test_escaped_from() {
);
let msg = get_chat_msg(&t, chat_id, 0, 1).await;
assert_eq!(msg.text, "hello");
assert_eq!(msg.param.get_int(Param::WantsMdn).unwrap(), 1);
// MDN request from unencrypted message is ignored.
assert!(msg.param.get_int(Param::WantsMdn).is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -726,6 +728,7 @@ async fn test_resend_after_ndn() -> Result<()> {
Ok(())
}
// an NDN in a group does not make the whole message as failed
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_ndn_group_msg() -> Result<()> {
let t = TestContext::new().await;
@@ -758,7 +761,7 @@ async fn test_parse_ndn_group_msg() -> Result<()> {
let msg = Message::load_from_db(&t, msg_id).await?;
assert_eq!(msg.state, MessageState::OutFailed);
assert_eq!(msg.state, MessageState::OutDelivered);
let msgs = chat::get_chat_msgs(&t, msg.chat_id).await?;
assert!(matches!(
@@ -766,9 +769,6 @@ async fn test_parse_ndn_group_msg() -> Result<()> {
ChatItem::Message { msg_id } if msg_id == msg.id
));
t.assert_warn("Delivery Status Notification (Failure)")
.await;
Ok(())
}
@@ -2725,20 +2725,7 @@ async fn test_read_receipts_dont_create_chats() -> Result<()> {
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
assert_eq!(chats.len(), 0);
// Bob sends a read receipt.
let mdn_mimefactory = crate::mimefactory::MimeFactory::from_mdn(
&bob,
received_msg.from_id,
received_msg.rfc724_mid,
vec![],
)
.await?;
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_mdn = mdn_mimefactory.render(&bob, &bob_addr).await?;
let mdn_body = rendered_mdn.message;
// Alice receives the read receipt.
receive_imf(&alice, mdn_body.as_bytes(), false).await?;
alice.recv_mdn(&bob, &received_msg).await?;
// Chat should not pop up in the chatlist.
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
@@ -2761,20 +2748,7 @@ async fn test_read_receipts_dont_unmark_bots() -> Result<()> {
.await;
let received_msg = bob.get_last_msg().await;
// Bob sends a read receipt.
let mdn_mimefactory = crate::mimefactory::MimeFactory::from_mdn(
bob,
received_msg.from_id,
received_msg.rfc724_mid,
vec![],
)
.await?;
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_mdn = mdn_mimefactory.render(bob, &bob_addr).await?;
let mdn_body = rendered_mdn.message;
// Alice receives the read receipt.
receive_imf(alice, mdn_body.as_bytes(), false).await?;
alice.recv_mdn(bob, &received_msg).await?;
let msg = alice.get_last_msg_in(alice_chat.id).await;
assert_eq!(msg.state, MessageState::OutMdnRcvd);
let ab_contact = alice.add_or_lookup_contact(bob).await;
@@ -3366,6 +3340,35 @@ async fn test_blocked_contact_creates_group() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_blocked_contact_sends_reaction() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_msg_id = tcm.send_recv_accept(alice, bob, "Hi!").await.id;
let chat = alice.get_chat(bob).await;
chat.id.block(alice).await?;
crate::reaction::send_reaction(bob, bob_msg_id, "👍").await?;
let sent = bob.pop_sent_msg().await;
alice.recv_msg_hidden(&sent).await;
alice.emit_event(EventType::Test);
while let Some(ev) = alice.evtracker.recv().await {
match ev.typ {
EventType::IncomingReaction { .. } => {
panic!("Alice is not supposed to receive a notification, since she blocked Bob")
}
EventType::Test => break,
_ => {}
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_outgoing_undecryptable() -> Result<()> {
let alice = &TestContext::new().await;
+3 -3
View File
@@ -341,7 +341,7 @@ async fn background_fetch_from_transport(
let folder = connection.folder.clone();
connection
.fetch_move_delete(context, &mut session, &folder)
.fetch_delete(context, &mut session, &folder)
.await
}
@@ -556,9 +556,9 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, mut session: Session)
// Fetch the watched folder.
connection
.fetch_move_delete(ctx, &mut session, &watch_folder)
.fetch_delete(ctx, &mut session, &watch_folder)
.await
.context("fetch_move_delete")?;
.context("fetch_delete")?;
download_known_post_messages_without_pre_message(ctx, &mut session).await?;
download_msgs(ctx, &mut session)
+16 -21
View File
@@ -33,14 +33,14 @@ pub(crate) use qrinvite::QrInvite;
use crate::token::Namespace;
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_');
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_').remove(b'@');
fn inviter_progress(
context: &Context,
contact_id: ContactId,
chat_id: ChatId,
chat_type: Chattype,
) -> Result<()> {
) {
// No other values are used.
let progress = 1000;
context.emit_event(EventType::SecurejoinInviterProgress {
@@ -49,8 +49,6 @@ fn inviter_progress(
chat_type,
progress,
});
Ok(())
}
/// Shorten name to max. `length` characters.
@@ -121,21 +119,18 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
let fingerprint = self_fingerprint(context).await?;
let self_addr = context.get_primary_self_addr().await?;
let self_addr_urlencoded = utf8_percent_encode(&self_addr, DISALLOWED_CHARACTERS).to_string();
let self_addrs = context.get_self_addrs().await?;
let mut encoded_addrs = self_addrs
.iter()
.map(|addr| utf8_percent_encode(addr, DISALLOWED_CHARACTERS).to_string());
let self_addr_urlencoded = encoded_addrs.next().context("No self addr configured")?;
let encoded_extra_relays: Vec<String> = encoded_addrs.collect();
let r_param = context
.get_self_addrs()
.await?
.into_iter()
.filter(|addr| *addr != self_addr)
.reduce(|acc, addr| {
format!(
"{acc},{}",
utf8_percent_encode(&addr, DISALLOWED_CHARACTERS)
)
})
.map_or(String::default(), |addrs| format!("&r={addrs}"));
let r_param = if encoded_extra_relays.is_empty() {
"".to_string()
} else {
format!("&r={}", encoded_extra_relays.join(","))
};
let self_name = context
.get_config(Config::Displayname)
@@ -659,7 +654,7 @@ pub(crate) async fn handle_securejoin_handshake(
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
}
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
inviter_progress(context, contact_id, joining_chat_id, chat.typ);
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
@@ -670,7 +665,7 @@ pub(crate) async fn handle_securejoin_handshake(
.await
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, chat_id, Chattype::Single)?;
inviter_progress(context, contact_id, chat_id, Chattype::Single);
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
}
@@ -817,7 +812,7 @@ pub(crate) async fn observe_securejoin_on_other_device(
// and tests which don't care about the chat ID,
// so we pass invalid chat ID here.
let chat_id = ChatId::new(0);
inviter_progress(context, contact_id, chat_id, chat_type)?;
inviter_progress(context, contact_id, chat_id, chat_type);
}
if matches!(step, SecureJoinStep::MemberAdded) {
+67 -2
View File
@@ -6,6 +6,7 @@ use crate::chat::{CantSendReason, ChatId, add_contact_to_chat, remove_contact_fr
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::key::self_fingerprint;
use crate::message::rfc724_mid_exists_ext;
use crate::qr::Qr;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, messages_e2ee_info_msg};
@@ -13,6 +14,7 @@ use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg, sync,
};
use crate::transport::add_pseudo_transport;
#[derive(PartialEq)]
enum SetupContactCase {
@@ -48,7 +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
let alice_addr = &alice
.get_config(Config::ConfiguredAddr)
.await
.unwrap()
@@ -116,7 +118,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.recipients, *alice_addr);
assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap());
let msg = alice.parse_msg(&sent).await;
assert!(msg.signature.is_none());
assert_eq!(
@@ -1142,6 +1144,43 @@ async fn test_get_securejoin_qr_name_is_last() -> Result<()> {
Ok(())
}
/// Test that addresses in QR codes are percent-encoded.
/// `@` should not be encoded unnecessarily,
/// since this would just make the QR code longer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_securejoin_qr_encoding() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// `@` in email addresses must not be percent-encoded:
add_pseudo_transport(alice, "asdf@example.org").await?;
// But `%` does need percent-encoding:
add_pseudo_transport(alice, "jk%l@example.net").await?;
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'"
);
assert!(
qr.contains("r=asdf@example.org,alice@example.org"),
"{qr} doesn't contain 'r=asdf@example.org,alice@example.org'"
);
let qr = check_qr(bob, &qr).await?;
let Qr::AskVerifyContact { mut addrs, .. } = qr else {
unreachable!()
};
addrs.sort();
assert_eq!(
addrs,
vec!["alice@example.org", "asdf@example.org", "jk%l@example.net",]
);
Ok(())
}
/// QR codes should not get arbitrary big because of long names.
/// The truncation, however, should not let the url end with a `.`, which is a call for trouble in linkfiers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1525,3 +1564,29 @@ async fn test_deduplicate_member_added() -> Result<()> {
Ok(())
}
/// Tests that a handled join request is also marked as "deleted"
/// in the database, so that if a copy of the request arrives via
/// other relays in the future, then this copy will also be deleted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_join_request_deleted_on_all_relays() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = chat::create_group(alice, "Group").await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
bob.add_or_lookup_contact_id(alice).await;
join_securejoin(bob, &qr).await?;
let request = bob.pop_sent_msg().await;
alice.recv_msg_trash(&request).await;
let rfc724_mid = Message::load_from_db(bob, request.sender_msg_id)
.await?
.rfc724_mid;
let (_, deleted) = rfc724_mid_exists_ext(alice, &rfc724_mid, "deleted=1")
.await?
.unwrap();
assert!(deleted);
Ok(())
}
+33 -25
View File
@@ -22,7 +22,6 @@ use crate::log::{LogExt, warn};
use crate::message::Message;
use crate::message::{self, MsgId};
use crate::mimefactory;
use crate::mimefactory::MimeFactory;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionBufStream;
use crate::scheduler::connectivity::ConnectivityStore;
@@ -356,7 +355,7 @@ pub(crate) async fn insert_into_smtp(
queued_msg: &QueuedMail,
) -> Result<()> {
let now = tools::time();
let msg_id = message::insert_tombstone(context, rfc724_mid).await?;
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))
@@ -652,6 +651,24 @@ pub(crate) async fn send_smtp_messages(context: &Context, connection: &mut Smtp)
Ok(())
}
async fn delete_mdns_by_rfc724_mid(
context: &Context,
rfc724_mid: &str,
additional_rfc724_mids: Vec<String>,
) -> Result<()> {
context
.sql
.transaction(|transaction| {
let mut stmt = transaction.prepare("DELETE FROM smtp_mdns WHERE rfc724_mid = ?")?;
stmt.execute((rfc724_mid,))?;
for additional_rfc724_mid in additional_rfc724_mids {
stmt.execute((additional_rfc724_mid,))?;
}
Ok(())
})
.await
}
/// Tries to send MDN for message identified by `rfc724_mdn` to `contact_id`.
///
/// Attempts to aggregate additional MDNs for `contact_id` into sent MDN.
@@ -687,28 +704,30 @@ async fn send_mdn_rfc724_mid(
)
.await?;
let mimefactory = MimeFactory::from_mdn(
let queued_mdn = mimefactory::mdn(
context,
contact_id,
rfc724_mid.to_string(),
rfc724_mid,
additional_rfc724_mids.clone(),
)
.await?;
let encrypted = mimefactory.will_be_encrypted();
let mut recipients = if contact_id == ContactId::SELF {
Vec::new()
} else {
mimefactory.recipients()
};
let bcc_self = queued_mdn.bcc_self;
let encrypted = queued_mdn.encryption.is_encrypted();
let mut recipients = queued_mdn.recipients.clone();
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let from = smtp
.from
.as_ref()
.context("No From address, not connected")?
.to_string();
let rendered_msg = Box::pin(mimefactory.render(context, &from)).await?;
let rendered_msg =
mimefactory::render_queued_mail(queued_mdn, &public_key, &secret_key, from.clone())?;
let body = rendered_msg.message;
if context.get_config_bool(Config::BccSelf).await? {
if bcc_self {
add_self_recipients(context, &mut recipients, encrypted, from).await?;
}
let recipients: Vec<_> = recipients
@@ -720,24 +739,13 @@ async fn send_mdn_rfc724_mid(
.ok()
})
.collect();
message::insert_tombstone(context, &rendered_msg.rfc724_mid).await?;
message::insert_tombstone(context, &rendered_msg.rfc724_mid, false).await?;
match smtp_send(context, &recipients, &body, smtp, None).await {
SendResult::Success => {
if !recipients.is_empty() {
info!(context, "Successfully sent MDN for {rfc724_mid}.");
}
context
.sql
.transaction(|transaction| {
let mut stmt =
transaction.prepare("DELETE FROM smtp_mdns WHERE rfc724_mid = ?")?;
stmt.execute((rfc724_mid,))?;
for additional_rfc724_mid in additional_rfc724_mids {
stmt.execute((additional_rfc724_mid,))?;
}
Ok(())
})
.await?;
delete_mdns_by_rfc724_mid(context, rfc724_mid, additional_rfc724_mids).await?;
Ok(true)
}
SendResult::Retry => {
+1 -1
View File
@@ -109,7 +109,7 @@ pub struct SideEffects {
}
/// Email message ready to be queued with the side effects that should be applied at the same time.
pub(crate) type ToBeQueuedMail = (QueuedMail, Option<SideEffects>);
pub(crate) type ToBeQueuedMail = (QueuedMail, SideEffects);
/// Process side effects and store queued mail.
pub(crate) fn enqueue_mail(
+7 -1
View File
@@ -14,7 +14,7 @@ use crate::config::Config;
use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
use crate::ephemeral::start_ephemeral_timers;
use crate::imex::BLOBS_BACKUP_NAME;
use crate::imex::{self, BLOBS_BACKUP_NAME};
use crate::location;
use crate::log::{LogExt, warn};
use crate::message::MsgId;
@@ -827,6 +827,12 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
);
}
imex::maybe_readd_backup_transfer_msg(context)
.await
.context("Failed to re-add backup transfer message")
.log_err(context)
.ok();
if let Err(err) = incremental_vacuum(context).await {
warn!(context, "Failed to run incremental vacuum: {err:#}.");
}
+11 -12
View File
@@ -32,8 +32,6 @@ fn migrate_key_contacts(
context: &Context,
transaction: &mut rusqlite::Transaction<'_>,
) -> std::result::Result<(), anyhow::Error> {
info!(context, "Starting key-contact transition.");
// =============================== Step 1: ===============================
// Alter tables
transaction.execute_batch(
@@ -79,13 +77,12 @@ fn migrate_key_contacts(
.optional()
.context("Step 0")?
else {
info!(
context,
"Not yet configured, no need to migrate key-contacts"
);
// Not yet configured, no need to migrate key-contacts.
return Ok(());
};
info!(context, "Starting key-contact transition.");
// =============================== Step 2: ===============================
// Create up to 3 new contacts for every contact that has a peerstate:
// one from the Autocrypt key fingerprint, one from the verified key fingerprint,
@@ -1936,14 +1933,9 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
inc_and_check(&mut migration_version, 132)?;
if dbversion < migration_version {
let start = Time::now();
sql.execute_migration_transaction(|t| migrate_key_contacts(context, t), migration_version)
.await?;
info!(
context,
"key-contacts migration took {:?} in total.",
time_elapsed(&start),
);
// Schedule `msgs_to_key_contacts()`.
context
.set_config_internal(Config::LastHousekeeping, None)
@@ -2673,6 +2665,13 @@ CREATE TABLE smtp2 (
.await?;
}
inc_and_check(&mut migration_version, 167)?;
if dbversion < migration_version {
// The default relay candidates are no longer seeded into the table.
sql.execute_migration("DELETE FROM relay_candidates", migration_version)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?
+11 -1
View File
@@ -384,6 +384,7 @@ impl Context {
async fn sync_message_deletion(&self, msgs: &[String]) -> Result<()> {
let mut modified_chat_ids = BTreeSet::new();
let mut pinned_messages_changed_chat_ids = BTreeSet::new();
let mut msg_ids = Vec::new();
for rfc724_mid in msgs {
if let Some(msg_id) = message::rfc724_mid_exists(self, rfc724_mid).await? {
@@ -391,6 +392,9 @@ impl Context {
message::delete_msg_locally(self, &msg).await?;
msg_ids.push(msg.id);
modified_chat_ids.insert(msg.chat_id);
if msg.is_pinned() {
pinned_messages_changed_chat_ids.insert(msg.chat_id);
}
} else {
warn!(self, "Sync message delete: Database entry does not exist.");
}
@@ -398,7 +402,13 @@ impl Context {
warn!(self, "Sync message delete: {rfc724_mid:?} not found.");
}
}
message::delete_msgs_locally_done(self, &msg_ids, modified_chat_ids).await?;
message::delete_msgs_locally_done(
self,
&msg_ids,
modified_chat_ids,
pinned_messages_changed_chat_ids,
)
.await?;
Ok(())
}
}
+21 -1
View File
@@ -193,11 +193,21 @@ impl TestContextManager {
to.recv_msg(&sent).await
}
// TODO: this just adds a transport
pub async fn change_addr(&self, test_context: &TestContext, new_addr: &str) {
self.section(&format!(
"{} changes her self address and reconfigures",
test_context.name()
));
test_context.add_transport(new_addr).await;
test_context.set_primary_self_addr(new_addr).await.unwrap();
// 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`.
@@ -840,6 +850,16 @@ ORDER BY id"
assert_eq!(received.chat_id, ChatId::TRASH);
}
/// Receives a read receipt from `reader`, who received `msg`.
pub async fn recv_mdn(&self, reader: &TestContext, msg: &Message) -> Result<()> {
let queued_mdn = mimefactory::mdn(reader, msg.from_id, &msg.rfc724_mid, vec![]).await?;
let mdn = mimefactory::render_queued_mail_with_context(queued_mdn, reader)
.await?
.message;
receive_imf(self, mdn.as_bytes(), false).await?;
Ok(())
}
/// Gets the most recent message ID of a chat.
///
/// Panics on errors or if the most recent message is a marker.
+1 -26
View File
@@ -220,6 +220,7 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
),
Some(&mut msg),
true,
time(),
)
.await
.ok();
@@ -520,32 +521,6 @@ where
}
}
pub(crate) trait ToOption<T> {
fn to_option(self) -> Option<T>;
}
impl<'a> ToOption<&'a str> for &'a String {
fn to_option(self) -> Option<&'a str> {
if self.is_empty() { None } else { Some(self) }
}
}
impl ToOption<String> for u16 {
fn to_option(self) -> Option<String> {
if self == 0 {
None
} else {
Some(self.to_string())
}
}
}
impl ToOption<String> for Option<i32> {
fn to_option(self) -> Option<String> {
match self {
None | Some(0) => None,
Some(v) => Some(v.to_string()),
}
}
}
#[expect(clippy::arithmetic_side_effects)]
pub(crate) fn remove_subject_prefix(last_subject: &str) -> String {
let subject_start = if last_subject.starts_with("Chat:") {
+36 -4
View File
@@ -276,14 +276,14 @@ impl ConfiguredLoginParam {
.await
}
/// Loads legacy configured param. Only used for tests and migration 131.
/// Loads legacy configured param. Only used for tests and the migration.
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_opt(Config::ConfiguredAddr)
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default()
.trim()
@@ -625,7 +625,7 @@ pub(crate) async fn sync_transports(
)
.collect();
let deleted_ids = context
let (deleted_ids, reelected) = context
.sql
.transaction(|transaction| {
let mut deleted_ids = Vec::new();
@@ -638,7 +638,8 @@ pub(crate) async fn sync_transports(
}
modified |= !deleted_ids.is_empty();
Ok(deleted_ids)
let reelected = maybe_update_sending_transport(transaction)?;
Ok((deleted_ids, reelected))
})
.await?;
@@ -646,6 +647,12 @@ 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
@@ -704,6 +711,31 @@ 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
+79
View File
@@ -226,6 +226,50 @@ 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)]
@@ -244,6 +288,14 @@ 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<()> {
@@ -274,6 +326,33 @@ async fn test_removing_last_transport() -> Result<()> {
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(())
}
/// Tests that a transport an older core unpublished is removed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync_unpublished_transport_removes_it() -> Result<()> {
+13 -13
View File
@@ -27,7 +27,7 @@ impl Context {
Ok(param.parse().unwrap_or_default())
},
)?;
let update = param.update_timestamp(scope, new_timestamp)?;
let update = param.update_timestamp(scope, new_timestamp);
if update {
transaction.execute(
"UPDATE contacts SET param=? WHERE id=?",
@@ -57,7 +57,7 @@ impl ChatId {
let param: String = row.get(0)?;
Ok(param.parse().unwrap_or_default())
})?;
let update = param.update_timestamp(scope, new_timestamp)?;
let update = param.update_timestamp(scope, new_timestamp);
if update {
transaction.execute(
"UPDATE chats SET param=? WHERE id=?",
@@ -73,13 +73,13 @@ impl ChatId {
impl Params {
/// Updates a param's timestamp in memory, if reasonable.
/// Returns true if the caller shall update the settings belonging to the scope.
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> Result<bool> {
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> bool {
let old_timestamp = self.get_i64(scope).unwrap_or_default();
if new_timestamp >= old_timestamp {
self.set_i64(scope, new_timestamp);
return Ok(true);
return true;
}
Ok(false)
false
}
}
@@ -96,18 +96,18 @@ mod tests {
let mut params = Params::new();
let ts = time();
assert!(params.update_timestamp(Param::LastSubject, ts)?);
assert!(params.update_timestamp(Param::LastSubject, ts)?); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10)?);
assert!(!params.update_timestamp(Param::LastSubject, ts)?); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0)?);
assert!(params.update_timestamp(Param::LastSubject, ts));
assert!(params.update_timestamp(Param::LastSubject, ts)); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10));
assert!(!params.update_timestamp(Param::LastSubject, ts)); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0));
assert_eq!(params.get_i64(Param::LastSubject).unwrap(), ts + 10);
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // ... also on multiple calls
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // ... also on multiple calls
assert_eq!(params.get_i64(Param::GroupNameTimestamp).unwrap(), 0);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1)?);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1));
assert_eq!(params.get_i64(Param::AvatarTimestamp), None);
Ok(())
+3 -3
View File
@@ -350,7 +350,7 @@ impl Context {
if let Some(ref document) = status_update_item.document
&& instance
.param
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)?
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)
{
instance.param.set(Param::WebxdcDocument, document);
param_changed = true;
@@ -359,10 +359,10 @@ impl Context {
if let Some(ref summary) = status_update_item.summary
&& instance
.param
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)?
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)
{
let summary = sanitize_bidi_characters(summary);
instance.param.set(Param::WebxdcSummary, summary.clone());
instance.param.set(Param::WebxdcSummary, summary);
param_changed = true;
}
+12 -14
View File
@@ -1201,30 +1201,28 @@ async fn test_get_webxdc_blob_with_subdirs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest() -> Result<()> {
let result = parse_webxdc_manifest(r#"key = syntax error"#.as_bytes());
let result = parse_webxdc_manifest(br#"key = syntax error"#);
assert!(result.is_err());
let manifest = parse_webxdc_manifest(r#"no_name = "no name, no icon""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"no_name = "no name, no icon""#)?;
assert_eq!(manifest.name, None);
let manifest = parse_webxdc_manifest(r#"name = "name, no icon""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"name = "name, no icon""#)?;
assert_eq!(manifest.name, Some("name, no icon".to_string()));
let manifest = parse_webxdc_manifest(
r#"name = "foo"
icon = "bar""#
.as_bytes(),
br#"name = "foo"
icon = "bar""#,
)?;
assert_eq!(manifest.name, Some("foo".to_string()));
let manifest = parse_webxdc_manifest(
r#"name = "foz"
br#"name = "foz"
icon = "baz"
add_item = "that should be just ignored"
[section]
sth_for_the = "future""#
.as_bytes(),
sth_for_the = "future""#,
)?;
assert_eq!(manifest.name, Some("foz".to_string()));
Ok(())
@@ -1232,13 +1230,13 @@ sth_for_the = "future""#
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
let manifest = parse_webxdc_manifest(r#"min_api = 3"#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"min_api = 3"#)?;
assert_eq!(manifest.min_api, Some(3));
let result = parse_webxdc_manifest(r#"min_api = "1""#.as_bytes());
let result = parse_webxdc_manifest(br#"min_api = "1""#);
assert!(result.is_err());
let result = parse_webxdc_manifest(r#"min_api = 1.2"#.as_bytes());
let result = parse_webxdc_manifest(br#"min_api = 1.2"#);
assert!(result.is_err());
Ok(())
@@ -1246,10 +1244,10 @@ async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_source_code_url() -> Result<()> {
let result = parse_webxdc_manifest(r#"source_code_url = 3"#.as_bytes());
let result = parse_webxdc_manifest(br#"source_code_url = 3"#);
assert!(result.is_err());
let manifest = parse_webxdc_manifest(r#"source_code_url = "https://foo.bar""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"source_code_url = "https://foo.bar""#)?;
assert_eq!(
manifest.source_code_url,
Some("https://foo.bar".to_string())
@@ -2,5 +2,4 @@ OutBroadcast#Chat#1001: My Channel [1 member(s)]🔇 Icon: e9b6c7a78aa2e4f415644
--------------------------------------------------------------------------------
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#1002🔒: Me (Contact#Contact#Self): Channel image changed. [INFO] √
Msg#1005🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
--------------------------------------------------------------------------------
@@ -1,7 +1,6 @@
OutBroadcast#Chat#1001: Channel [0 member(s)]🔇
--------------------------------------------------------------------------------
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
Msg#1009🔒: Me (Contact#Contact#Self): hi √
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
--------------------------------------------------------------------------------
@@ -1,7 +1,6 @@
OutBroadcast#Chat#1001: Channel [0 member(s)]🔇
--------------------------------------------------------------------------------
Msg#1002: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
Msg#1009🔒: Me (Contact#Contact#Self): hi √
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
--------------------------------------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More