Compare commits

...
Author SHA1 Message Date
link2xt 0e5b440a4d api!: remove SQLCipher support 2026-09-23 22:32:05 +00:00
link2xt 5b49541e16 chore: update rusqlite from 0.37 to 0.40.2
Largest change comes from removal of u64 and usize serialization
in rusqlite 0.38.0.
It is possible to enable it back with `fallible_uint` feature
as suggested in https://github.com/rusqlite/rusqlite/pull/1732,
but there is a good reason for this change
as it may result in unexpected failures when
trying to store u64 integers that don't fit in i64,
so I don't enable it back.
With this change conversions between i64 and u64 are explicit.

Using less `usize` is generally good as its size
differs on 32-bit platforms that are rarely tested
but still supported and we have changes that reduce its usage
such as 0defa117a0,
014d2ace76 etc.
2026-09-23 18:31:27 +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
link2xt fae6a63023 fix: use correct address for Bcc-self in unencrypted mails 2026-09-19 17:44:10 +00:00
link2xt 54e16429b8 fix: use correct From address when sending MDNs 2026-09-19 17:44:10 +00:00
biørnandl 946120e427 feat: mark autorelays for relay operators (#8701)
this PR adds a hack to allow relay operators
to differ between manually created relays and autorelays.

this is a precaution in case unexpected things happen when introducing
autorelay; relay operators then can deny creation on `password_len ==
23`, without shutting down manual creation completely.

depeding on final initTransport() is done,
it may still be that the first created relay is marked as being manual,
but that seems fine.

`password_len` of the autmatically generated password was chosen as
there is otherwise not much data sent -
chatmail avoids visible data and metadata everywhere.

this hack is about to be removed again asap,
once we have some experiences with multi relay.

---------

Co-authored-by: l <link2xt@testrun.org>
2026-09-19 16:59:02 +02:00
link2xt e1d58706ce api!: remove Contact.get_name_n_addr() and related APIs
UIs should use display name everywhere and avoid displaying email addresses.

BREAKING CHANGE: dc_contact_get_name_n_addr() CFFI is removed
BREAKING CHANGE: JSON-RPC contact objects don't have nameAndAddr field anymore
2026-09-19 08:16:30 +00:00
holger krekel 7876115d86 fix: use max_smtp_rcpt_to chunking for the actual transport we are sending from
removes another `ConfiguredAddr` usage
and a bug that the wrong max smtp recipients setting was used.
2026-09-18 23:37:45 +02:00
link2xt 0a7e697f50 refactor: add smtp::queue module
No changes except that smtp::is_queue_empty is renamed into smtp::queue::is_empty,
mimefactory::QueuedEncryption is renamed into smtp::queue::Encryption
and mimefactory::QueueSideEffects is renamed to smtp::queue::SideEffects.
mimefactory module does not know anything about smtp2 table anymore.
2026-09-18 18:23:16 +00:00
B. Petersen 06150d6dc2 docs: remove protect_autocrypt setting 2026-09-18 19:31:52 +02:00
link2xt 310dda494c feat: do not use ConfiguredAddr when connecting to SMTP 2026-09-18 17:10:29 +00:00
link2xt 8f39d7510f feat: do not restart I/O when setting configured_addr 2026-09-18 17:10:29 +00:00
holger krekel 44ab013351 fix: don't use extra STUN nine server for fallback
just fallback to turn.delta.chat
which also answers STUN "what is my IP address" requests.
2026-09-18 14:34:49 +02:00
link2xt f7c97d8557 test: add pseudo transport explicitly rather than by setting ConfiguredAddr
ConfiguredAddr is going to be removed, but we need to be able to add fake transports,
also for existing offline Python tests.
2026-09-18 08:09:33 +00:00
link2xt 78caa6f53c fix: emit SmtpMessageSent event after deleting the message from SMTP queue
This is mostly needed to fix flaky
tests/test_something.py::test_is_sending_finished
by making sure that is_sending_finished() return false
once we got an event that message was sent.
2026-09-17 18:59:20 +00:00
link2xt 349c9650e3 fix: do not send a sync message when changing "configured_addr"
Sending transport is not synchronized since
cd42efb36d
(PR https://github.com/chatmail/core/pull/8510)
so there is no need to send a sync message
when the sending transport is changed.
2026-09-17 18:50:09 +00:00
holger krekel 96e84d85a5 api!: remove is_chatmail and the XCHATMAIL capability
This removes the last usages of the deprecated `is_chatmail` and
no `XCHATMAIL` IMAP capability is read anymore.
UIs are not using it for a longer time anymore.

The self-reporting statistics now set `is_chatmail` field to
`true`: all transports have relay-typical metadata
`false`: at least one doesn't
`null`: we don't know

BREAKING CHANGE: `is_chatmail` is no longer a known config key.
2026-09-17 20:42:41 +02:00
holger krekel 57b2c000e6 feat: base server-side message deletion on force_encryption
don't use `is_chatmail` for determining whether to delete messages on the server:
whether a downloaded message may be dropped from the relay
depends on whether another device still needs it (BccSelf)
and on whether plaintext mail is allowed (ForceEncryption).
2026-09-17 20:42:41 +02:00
holger krekel 8014c2af2f feat: remove last usage of XDELTAPUSH capability 2026-09-17 20:42:41 +02:00
holger krekel 5db55ac4de fix: make background_fetch not wait on or trigger smtp connections
Also adds tests and docs to respective functions,
clarifying background fetching behaviour and the `ACCOUNTS_BACKGROUND_FETCH_DONE` event,
that came up in questions/discussions with UI devs lately.
2026-09-17 19:26:18 +02:00
link2xt 44c2febbe1 test(direct_imap): always pass mark_seen=False to fetch()
mark_seen=True is the default and translates to fetching BODY
of the message. mark_seen=False translates to fetching BODY.PEEK
that is described in IETF RFC 3501 as
"An alternate form of BODY[<section>] that does not implicitly set the \Seen flag."

It is unexpected unless you know this detail of IMAP protocol already,
but this is not going to be fixed in imap_tools: <https://github.com/ikvk/imap_tools/issues/179>
2026-09-17 13:58:42 +00:00
link2xt 555b8b8b51 test: do not fetch all messages when direct_imap is created
This code is likely from the time when we reused mailboxes
in python/ tests, it does not do anything anymore.
2026-09-17 13:58:42 +00:00
link2xt dc9bba0697 refactor(sql): disable double-quoted string literals
Double-quoted string literals are not standard
and may be accidentally treated as an identifier
if there is an identifier with the same name
as the string contents:
https://sqlite.org/quirks.html#double_quoted_string_literals_are_accepted
2026-09-17 13:31:41 +00:00
link2xt 3858c47ceb api!: remove default value for "addr" config
"addr" is already deprecated and its default value was using "configured_addr".
We want to get rid of "configured_addr" too, but users should at least
not access it through deprecated config.
2026-09-17 12:46:43 +00:00
link2xt 6b1d982cfc test: rename test_aeap_transition_{0,1} to test_aeap_transition_{single,group} 2026-09-17 09:31:15 +00:00
link2xt 5a71fe101e test: do not talk about "inconsistent key state" in test_securejoin_after_contact_resetup
"Inconsistent key state" referred to acpeerstates table row
with different Autocrypt and verified keys.
Since v2 and introduction of key contacts it is not possible for a contact
to have inconsistent key state, so this translates to having
two contacts with the same email address but different keys.
2026-09-17 09:30:51 +00:00
biørn c41657d7fd test: explicitly empty url in appversion updates (#8702)
desktop will probably not always use a URL, see
https://github.com/deltachat/deltachat-desktop/issues/6749,
make sure, this stays optional in the JSON.

successor of https://github.com/chatmail/core/pull/8557
2026-09-16 21:44:50 +02:00
link2xt 6c3a866892 docs: mention get_app_version() API in the changelog for 2.59.0
It was added in https://github.com/chatmail/core/pull/8557,
but the changelog entry just says "Client version information".
2026-09-16 15:46:25 +00:00
holger krekel 5007fc49c1 test: cross-core securejoin invites for every chat type 2026-09-16 16:09:09 +02:00
holger krekel b1da53a56b api!: remove verification methods from the FFI and JSON-RPC APIs
Core no longer tracks verification, so the API has nothing left
to report and UIs should drop their checkmark and "Introduced by" code.

BREAKING CHANGE: dc_contact_is_verified() and dc_contact_get_verifier_id() are removed.

BREAKING CHANGE: the JSON-RPC Contact object loses the `isVerified` and `verifierId` fields. A bot reading `snapshot.is_verified` now gets an `AttributeError` at runtime.

BREAKING CHANGE: the Python bindings lose `Contact.is_verified()` and `Contact.get_verifier()`.

BREAKING CHANGE: 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.
2026-09-16 16:09:09 +02:00
holger krekel d8912a98ac feat!: stop tracking contact verification
Since V2 a contact is its key, there is no address-to-key binding left to verify.

The JSONRPC and FFI APIs are unchanged and report nothing as verified.

BREAKING CHANGE: 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.
2026-09-16 16:09:09 +02:00
link2xt 373f1840a6 feat: queue messages for SMTP before encryption
Headers like From and Autocrypt are now added late,
right before sending the message over SMTP.
This way we advertise the latest list of transports
and use the correct From address in the encrypted part
even for messages queued while being offline.

BCC-self recipients are also added late.
For unencrypted messages we only want to send a copy
to the sending address, but we don't know the sending address
when queueing the message.
Adding bcc-self recipients when dequeuing the message
also makes it possible to send copies to updated list of relays.
2026-09-16 13:06:06 +00:00
link2xt 81982273e8 refactor: separate QueuedEncryption
This is similar to mimefactory::Encryption,
but does not have email addresses for asymmetrically encrypted messages.
Queued messages don't need email addresses for public keys.
Addresses are only needed to render Autocrypt-Gossip headers.
2026-09-16 13:06:06 +00:00
link2xt 5be489790f refactor: add Encryption.is_encrypted() 2026-09-16 13:06:06 +00:00
link2xt 81ca5aa1ca test: cleanup get_smtp_rows_for_msg()
It was incorrectly converting unused row ID to MsgId type
and selecting already known msg_id.
2026-09-16 13:06:06 +00:00
link2xt 15488ed210 chore: fix some types in deltachat_rpc_client 2026-09-16 11:43:25 +00:00
link2xt f03088f247 chore: fix nightly "cargo" warnings 2026-09-16 11:43:06 +00:00
biørnandadb d4115e720d docs: add hint about how to reset an invitation (#8160)
this PR adds a hint about how to reset a QR code. 

the API is as-is, just now, a refactoring here is also unwanted. the
jsonrpc documentation needs to improved in general, this is known, and
also not done by this PR

closes #7985

---------

Co-authored-by: adb <asieldbenitez@gmail.com>
2026-09-15 16:19:07 +02:00
link2xt abb0a119cb chore: update rustls to 0.23.45
This fixes https://rustsec.org/advisories/RUSTSEC-2026-0285
2026-09-15 12:11:18 +00:00
holger krekel 63364da79c chore: cleanup "primary" wording in comments 2026-09-15 13:44:38 +02:00
holger krekel a8e1110bcd fix: always emit AccountsBackgroundFetchDone
A second background_fetch() while one is already running returned
without emitting the event, and the FFI returned 1 for it,
so a UI waiting for an event hangs dc_get_next_event().
Emit the event in any case, so waiting for it is safe.
2026-09-13 10:29:52 +02:00
holger krekel dced877c90 feat: perform background fetch from all transports
With I/O stopped, `background_fetch()` connected only to the transport of `configured_addr`
and we now instead fan out to all transports in a controlled loop.
If a first transport finished fetching new messages
cancel all other attempts and return.

This is meant to address the problem that amzd described
where a profile with one functioning and one hanging transport,
shows the first notification, then hangs 15 seconds waiting for the hanging transport.
meanwhile a second NSE arrives and dies, and the second message is not notified
or only generically.

Also drop the quota check from this background fetch path:
its result is in-memory only, discarded when the iOS notification service exits,
and the regular scheduler fetching refreshes it every 60s anyway.
Moreover, quota errors/running full is pretty rare
since relays generally automatically stay under quota these days.
It's another round trip for each transport of each profile and simply not necessary.

Also adds previously missing online tests.
2026-09-13 10:29:52 +02:00
link2xt 44387f5e58 api: add JSON-RPC API is_sending_finished() 2026-09-12 23:35:58 +00:00
biørnandl 0b2051cda4 feat: better quality of image recoding (#8682)
this PR introduces a better quality of image recoding, consuming max.
900k instead of 500k (average is much less). the PR roughly doubles the
number of pixels sent in an image.

the old 1280px were set 8 years ago,
data and storage has improved since then,
so it is reasonable to double the number if pixels used for sending an
image.

this will be a quality boost for many images,
while not resulting in a doubled size for all of them; many images will
only be a little larger in bytes, see test.

we could always go higher, of course, but it comes at costs of relay
storage and data, so we stay conservative, even in that increase.

the "worse quality" setting is not adapted on purpose, there we really
stay at the end of what is bearable :)

cc @adbenitez

---------

Co-authored-by: l <link2xt@testrun.org>
2026-09-12 13:44:02 +00:00
link2xt 9e8c3a63ad docs: update sys.msgsize_max_recommended documentation
Errors are no longer shown as a toast by UIs
and it is no longer true that any warning or error is logged.
2026-09-11 22:35:33 +00:00
link2xt f725e91b2a chore: bump version to 2.61.0-dev 2026-09-11 21:14:09 +00:00
133 changed files with 3754 additions and 4303 deletions
+110
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
@@ -106,6 +213,7 @@
- [**breaking**] Remove deprecated `dc_chat_is_protected()`.
- Deprecate `dc_chat_get_info_json()` ([#8580](https://github.com/chatmail/core/pull/8580))
- New `get_app_version()` JSON-RPC API to get information about available updates.
### Features / Changes
@@ -8793,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
+87 -56
View File
@@ -1327,7 +1327,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.60.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1435,7 +1435,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.60.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1456,14 +1456,14 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc-bindings"
version = "2.60.0"
version = "2.63.0-dev"
dependencies = [
"deltachat-jsonrpc",
]
[[package]]
name = "deltachat-repl"
version = "2.60.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1479,7 +1479,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.60.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1508,7 +1508,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.60.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -2091,6 +2091,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -2411,16 +2417,34 @@ checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.10.0"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown",
"hashbrown 0.17.1",
]
[[package]]
@@ -2954,7 +2978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.15.4",
]
[[package]]
@@ -3261,11 +3285,12 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
[[package]]
name = "js-sys"
version = "0.3.77"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"once_cell",
"cfg-if",
"futures-util",
"wasm-bindgen",
]
@@ -3344,12 +3369,11 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"openssl-sys",
"pkg-config",
"vcpkg",
]
@@ -3421,7 +3445,7 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
"hashbrown 0.15.4",
]
[[package]]
@@ -5211,10 +5235,20 @@ dependencies = [
]
[[package]]
name = "rusqlite"
version = "0.37.0"
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.20",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
@@ -5222,6 +5256,7 @@ dependencies = [
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
@@ -5282,9 +5317,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"brotli",
"brotli-decompressor",
@@ -5292,7 +5327,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki 0.103.15",
"subtle",
"zeroize",
]
@@ -5329,9 +5364,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -5926,6 +5961,18 @@ dependencies = [
"der",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -6833,48 +6880,32 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn 2.0.118",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.50"
version = "0.4.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -6882,22 +6913,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.118",
"wasm-bindgen-backend",
"syn 3.0.4",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
@@ -6917,9 +6948,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.77"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
dependencies = [
"js-sys",
"wasm-bindgen",
+4 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.60.0"
version = "2.63.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
@@ -85,7 +85,7 @@ quick-xml = { version = "0.41", features = ["escape-html"] }
rand-old = { package = "rand", version = "0.8" }
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rusqlite = { workspace = true, features = ["backup"] }
sanitize-filename = { workspace = true }
sdp = "0.17.1"
serde_json = { workspace = true }
@@ -184,7 +184,6 @@ base64 = "0.22"
chrono = { version = "0.4.44", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat-jsonrpc-bindings = { path = "deltachat-jsonrpc-bindings", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.32"
futures-lite = "2.6.1"
@@ -195,7 +194,7 @@ nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.9"
regex = "1.12"
rusqlite = "0.37"
rusqlite = "0.40.2"
sanitize-filename = "0.6"
serde = "1.0"
serde_json = "1"
@@ -210,7 +209,7 @@ yerpc = "0.7"
default = ["vendored"]
internals = []
vendored = [
"rusqlite/bundled-sqlcipher-vendored-openssl",
"rusqlite/bundled",
"async-native-tls/vendored"
]
+1 -1
View File
@@ -104,7 +104,7 @@ pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
let mut name = sanitize_name(name);
// If the 'display name' is just the address, remove it:
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)" (see `get_name_n_addr()`).
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)".
// If the display name is empty, DC will just show the address when it needs a display name.
if name == addr {
name = "".to_string();
+1 -2
View File
@@ -1,9 +1,8 @@
[package]
name = "deltachat_ffi"
version = "2.60.0"
version = "2.63.0-dev"
description = "Deltachat FFI"
edition = "2024"
readme = "README.md"
license = "MPL-2.0"
keywords = ["deltachat", "chat", "openpgp", "email", "encryption"]
+115 -148
View File
@@ -304,21 +304,6 @@ dc_context_t* dc_context_new_closed (const char* dbfile);
int dc_context_open (dc_context_t *context, const char* passphrase);
/**
* Changes the passphrase on the open database.
* Deprecated 2025-11, see `dc_context_open()` for reasoning.
*
* Existing database must already be encrypted and the passphrase cannot be NULL or empty.
* It is impossible to encrypt unencrypted database with this method and vice versa.
*
* @memberof dc_context_t
* @param context The context object.
* @param passphrase The new passphrase.
* @return 1 on success, 0 on error.
*/
int dc_context_change_passphrase (dc_context_t* context, const char* passphrase);
/**
* Returns 1 if database is open.
*
@@ -453,18 +438,9 @@ char* dc_get_blobdir (const dc_context_t* context);
* always auto-downloaded.
* 0 = no limit (default).
* Changes affect future messages only.
* - `protect_autocrypt` = Enable Header Protection for Autocrypt header.
* This is an experimental option not compatible to other MUAs
* and older Delta Chat versions.
* 1 = enable.
* 0 = disable (default).
* - `gossip_period` = How often to gossip Autocrypt keys in chats with multiple recipients, in
* seconds. 2 days by default.
* This is not supposed to be changed by UIs and only used for testing.
* - `is_chatmail` = (deprecated) 1 if the the server is a chatmail server, 0 otherwise.
* This is deprecated, UIs should not behave differently
* for chatmail relays and classical email servers.
* Most usages in UIs can be replaced by `force_encryption`.
* - `is_muted` = Whether a context is muted by the user.
* Muted contexts should not sound, vibrate or show notifications.
* In contrast to `dc_set_chat_mute_duration()`,
@@ -526,9 +502,10 @@ int dc_set_config (dc_context_t* context, const char*
*
* - `sys.version` = get the version string e.g. as `1.2.3` or as `1.2.3special4`.
* - `sys.msgsize_max_recommended` = maximal recommended attachment size in bytes.
* All possible overheads are already subtracted and this value can be used e.g. for direct comparison
* with the size of a file the user wants to attach. If an attachment is larger than this value,
* an error (no warning as it should be shown to the user) is logged but the attachment is sent anyway.
* All possible overheads are already subtracted and this value can be used
* e.g. for direct comparison with the size of a file the user wants to attach.
* If an attachment is larger than this value, the message is sent anyway,
* but email servers are likely to reject the message when receiving it or before trying to send.
* - `sys.config_keys` = get a space-separated list of all config-keys available.
* The config-keys are the keys that can be passed to the parameter `key` of this function.
*
@@ -696,6 +673,21 @@ char* dc_get_connectivity_html (dc_context_t* context);
void dc_configure (dc_context_t* context);
/**
* Add fake transport that cannot be used to connect.
*
* Used for offline tests only.
*
* To add a transport, use JSON-RPC calls `add_or_update_transport`
* and `add_transport_from_qr` instead.
*
* @memberof dc_context_t
* @param context The context object.
* @param addr The email address of the new transport.
*/
void dc_add_pseudo_transport (dc_context_t* context, const char *addr);
/**
* Check if the context is already configured.
*
@@ -1777,8 +1769,6 @@ int dc_is_contact_in_chat (dc_context_t* context, uint32_t ch
* If the group is already _promoted_ (any message was sent to the group),
* all group members are informed by a special status message that is sent automatically by this function.
*
* If the group has group protection enabled, only verified contacts can be added to the group.
*
* Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
*
* @memberof dc_context_t
@@ -2277,7 +2267,7 @@ void dc_block_contact (dc_context_t* context, uint32_t co
/**
* Get encryption info for a contact.
* Get a multi-line encryption info, containing your fingerprint and the
* fingerprint of the contact, used e.g. to compare the fingerprints for a simple out-of-band verification.
* fingerprint of the contact, used e.g. to compare the fingerprints out-of-band.
*
* @memberof dc_context_t
* @param context The context object.
@@ -2444,14 +2434,12 @@ char* dc_imex_has_backup (dc_context_t* context, const char*
void dc_stop_ongoing_process (dc_context_t* context);
// out-of-band verification
// securejoin
#define DC_QR_ASK_VERIFYCONTACT 200 // id=contact
#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
@@ -2479,7 +2467,7 @@ void dc_stop_ongoing_process (dc_context_t* context);
* The QR code state is returned in dc_lot_t::state as:
*
* - DC_QR_ASK_VERIFYCONTACT with dc_lot_t::id=Contact ID:
* ask whether to verify the contact;
* ask whether to start chatting with the contact;
* if so, start the protocol with dc_join_securejoin().
*
* - DC_QR_ASK_VERIFYGROUP or DC_QR_ASK_VERIFYBROADCAST
@@ -2488,17 +2476,10 @@ void dc_stop_ongoing_process (dc_context_t* context);
* if so, start the protocol with dc_join_securejoin().
*
* - DC_QR_FPR_OK with dc_lot_t::id=Contact ID:
* contact fingerprint verified,
* contact fingerprint matches,
* 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().
@@ -2564,23 +2545,24 @@ dc_lot_t* dc_check_qr (dc_context_t* context, const char*
/**
* Get QR code text that will offer an Setup-Contact or Verified-Group invitation.
* Get QR code text that will offer a SecureJoin invitation.
*
* The scanning device will pass the scanned content to dc_check_qr() then;
* if dc_check_qr() returns
* DC_QR_ASK_VERIFYCONTACT, DC_QR_ASK_VERIFYGROUP or DC_QR_ASK_VERIFYBROADCAST
* an out-of-band-verification can be joined using dc_join_securejoin()
* the SecureJoin protocol can be started using dc_join_securejoin()
*
* The returned text will also work as a normal https:-link,
* so that the QR code is useful also without Delta Chat being installed
* or can be passed to contacts through other channels.
*
* To reset invitations, pass the link to dc_set_config_from_qr().
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id If set to a group-chat-id,
* the Verified-Group-Invite protocol is offered in the QR code;
* works for protected groups as well as for normal groups.
* If set to 0, the Setup-Contact protocol is offered in the QR code.
* the SecureJoin QR code for the group is returned.
* If set to 0, the setup contact QR code is returned.
* See https://securejoin.delta.chat/
* for details about both protocols.
* @return The text that should go to the QR code,
@@ -2606,7 +2588,7 @@ char* dc_get_securejoin_qr (dc_context_t* context, uint32_t ch
char* dc_get_securejoin_qr_svg (dc_context_t* context, uint32_t chat_id);
/**
* Continue a Setup-Contact or Verified-Group-Invite protocol
* Continue the SecureJoin protocol
* started on another device with dc_get_securejoin_qr().
* This function is typically called when dc_check_qr() returns
* lot.state=DC_QR_ASK_VERIFYCONTACT, lot.state=DC_QR_ASK_VERIFYGROUP or lot.state=DC_QR_ASK_VERIFYBROADCAST
@@ -3187,19 +3169,33 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts);
/**
* Perform a background fetch for all accounts in parallel with a timeout.
* Pauses the scheduler, fetches messages from imap and then resumes the scheduler.
*
* dc_accounts_background_fetch() was created for the iOS Background fetch.
* For an account with IO stopped, the scheduler is paused
* and every transport is fetched concurrently on a dedicated connection.
* The account is done as soon as one transport received messages, the others stop.
* Only one batch of messages is fetched per transport this way,
* so a larger backlog is left to the next call or to started IO.
*
* The `DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE` event is emitted at the end
* even in case of timeout, unless the function fails and returns 0.
* For an account with IO running, IMAP IDLE is interrupted on every transport
* and the account is done once every transport is.
*
* The call never waits for outgoing messages and never triggers sending them itself.
* Received messages may still queue replies, securejoin handshakes for example,
* which go out only while IO is running.
*
* The `DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE` event is emitted at the end,
* also on timeout, when another background fetch is already running
* and when the call is ignored because the timeout is too small,
* so it is safe to wait for the event whenever `accounts` is not NULL.
* Process all events until you get this one and you can safely return to the background
* without forgetting to create notifications caused by timing race conditions.
* without forgetting to create a generic notification if no message was fetched.
* The event carries no data identifying the call it belongs to,
* so it marks your own call only if no concurrent background fetch is happening.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
* @param timeout The timeout in seconds
* @return Return 1 if DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE was emitted and 0 otherwise.
* @return Return 0 if the call was ignored because `accounts` is NULL or the timeout is too small, 1 otherwise.
*/
int dc_accounts_background_fetch (dc_accounts_t* accounts, uint64_t timeout);
@@ -3569,7 +3565,6 @@ dc_lot_t* dc_chatlist_get_summary2 (dc_context_t* context, uint32_t ch
* last-message-state: @ref DC_STATE constant
* last-message-date:
* avatar-path: path-to-blobfile
* is_verified: yes/no
* @return a UTF8-encoded JSON string containing all requested info. Must be freed using dc_str_unref(). NULL is never returned.
*/
char* dc_chat_get_info_json (dc_context_t* context, size_t chat_id);
@@ -4893,6 +4888,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
*
@@ -4908,7 +4935,6 @@ uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg);
* By default, these names are equal,
* but functions working with contact names
* (e.g. dc_contact_get_name(), dc_contact_get_display_name(),
* dc_contact_get_name_n_addr(),
* dc_create_contact() or dc_add_address_book())
* only affect the given-name.
*/
@@ -4958,7 +4984,7 @@ char* dc_contact_get_addr (const dc_contact_t* contact);
* The function does not return the contact name as received from the network.
*
* This name is typically used in a form where the user can edit the name of a contact.
* To get a fine name to display in lists etc., use dc_contact_get_display_name() or dc_contact_get_name_n_addr().
* To get a fine name to display in lists etc., use dc_contact_get_display_name().
*
* @memberof dc_contact_t
* @param contact The contact object.
@@ -5013,23 +5039,6 @@ char* dc_contact_get_display_name (const dc_contact_t* contact);
#define dc_contact_get_first_name dc_contact_get_display_name
/**
* Get a summary of name and address.
*
* The returned string is either "Name (email@domain.com)" or just
* "email@domain.com" if the name is unset.
*
* The summary is typically used when asking the user something about the contact.
* The attached e-mail address makes the question unique, e.g. "Chat with Alan Miller (am@uniquedomain.com)?"
*
* @memberof dc_contact_t
* @param contact The contact object.
* @return A summary string, must be released using dc_str_unref().
* Never returns NULL.
*/
char* dc_contact_get_name_n_addr (const dc_contact_t* contact);
/**
* Get the contact's profile image.
* This is the image set by each remote user on their own
@@ -5083,18 +5092,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);
/**
@@ -5109,19 +5118,6 @@ int dc_contact_was_seen_recently (const dc_contact_t* contact);
int dc_contact_is_blocked (const dc_contact_t* contact);
/**
* Check if the contact
* can be added to protected chats.
*
* See dc_contact_get_verifier_id() for a guidance how to display these information.
*
* @memberof dc_contact_t
* @param contact The contact object.
* @return 0: contact is not verified.
* 2: SELF and contact have verified their fingerprints in both directions.
*/
int dc_contact_is_verified (dc_contact_t* contact);
/**
* Returns whether contact is a bot.
*
@@ -5146,36 +5142,6 @@ int dc_contact_is_bot (dc_contact_t* contact);
int dc_contact_is_key_contact (dc_contact_t* contact);
/**
* Return the contact ID that verified a contact.
*
* As verifier may be unknown,
* use dc_contact_is_verified() to check if a contact can be added to a protected chat.
*
* UI should display the information in the contact's profile as follows:
*
* - If dc_contact_get_verifier_id() != 0,
* display text "Introduced by ..."
* with the name of the contact
* formatted by dc_contact_get_name().
* Prefix the text by a green checkmark.
*
* - If dc_contact_get_verifier_id() == 0 and dc_contact_is_verified() != 0,
* display "Introduced" prefixed by a green checkmark.
*
* - if dc_contact_get_verifier_id() == 0 and dc_contact_is_verified() == 0,
* display nothing
*
* @memberof dc_contact_t
* @param contact The contact object.
* @return
* The contact ID of the verifier. If it is DC_CONTACT_ID_SELF,
* we verified the contact ourself. If it is 0, we don't have verifier information or
* the contact is not verified.
*/
uint32_t dc_contact_get_verifier_id (dc_contact_t* contact);
/**
* @class dc_lot_t
*
@@ -5949,14 +5915,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.
*
@@ -6206,7 +6164,18 @@ void dc_event_unref(dc_event_t* event);
/**
* Contact(s) created, renamed, verified, blocked or deleted.
* 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.
*
* @param data1 (int) contact_id of the changed contact or 0 on batch-changes or deletion.
* @param data2 0
@@ -6278,8 +6247,7 @@ void dc_event_unref(dc_event_t* event);
*
* @param data1 (int) The ID of the inviting contact.
* @param data2 (int) The progress as:
* 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself."
* (Bob has verified alice and waits until Alice does the same for him)
* 400=vg-/vc-request-with-auth sent, typically shown as "introducing myself."
* 1000=vg-member-added/vc-contact-confirm received
*/
#define DC_EVENT_SECUREJOIN_JOINER_PROGRESS 2061
@@ -6362,11 +6330,18 @@ void dc_event_unref(dc_event_t* event);
#define DC_EVENT_WEBXDC_REALTIME_ADVERTISEMENT 2151
/**
* Tells that the Background fetch was completed (or timed out).
* Tells that a call to dc_accounts_background_fetch() is done:
* the fetch completed, timed out, was stopped or was not started.
*
* For the call that started the fetch, this event acts as a marker:
* when you reach it, all events emitted during the fetch were processed.
* A call made while another background fetch is running gets the event immediately,
* and the running fetch keeps emitting events until its own marker.
*
* The event carries no data identifying the call it belongs to,
* so it marks your own call only if no concurrent background fetch is happening.
* Your own call has finished when dc_accounts_background_fetch() returns.
*
* This event acts as a marker, when you reach this event you can be sure
* that all events emitted during the background fetch were processed.
*
* This event is only emitted by the account manager
*/
@@ -6662,12 +6637,6 @@ void dc_event_unref(dc_event_t* event);
/// Used to build the string returned by dc_get_contact_encrinfo().
#define DC_STR_FINGERPRINTS 30
/// "%1$s verified"
///
/// Used in status messages.
/// - %1$s will be replaced by the name of the verified contact
#define DC_STR_CONTACT_VERIFIED 35
/// "Archived chats"
///
/// Used as the name for the corresponding chatlist entry.
@@ -6846,7 +6815,7 @@ void dc_event_unref(dc_event_t* event);
///
/// Added as an info-message directly after scanning a QR code for joining a group.
/// May be followed by the info-messages
/// #DC_STR_SECURE_JOIN_REPLIES, #DC_STR_CONTACT_VERIFIED and #DC_STR_MSGADDMEMBER.
/// #DC_STR_SECURE_JOIN_REPLIES and #DC_STR_MSGADDMEMBER.
///
/// `%1$s` and `%2$s` will be replaced by name of the inviter.
#define DC_STR_SECURE_JOIN_STARTED 117
@@ -6855,15 +6824,13 @@ void dc_event_unref(dc_event_t* event);
///
/// Info-message on scanning a QR code for joining a group.
/// Added after #DC_STR_SECURE_JOIN_STARTED.
/// If the handshake allows to skip a step and go for #DC_STR_CONTACT_VERIFIED directly,
/// this info-message is skipped.
///
/// `%1$s` will be replaced by the name of the inviter.
#define DC_STR_SECURE_JOIN_REPLIES 118
/// "Scan to chat with %1$s"
///
/// Subtitle for verification qrcode svg image generated by the core.
/// Subtitle for the invite qrcode svg image generated by the core.
///
/// `%1$s` will be replaced by name of the inviter.
#define DC_STR_SETUP_CONTACT_QR_DESC 119
+33 -79
View File
@@ -31,6 +31,7 @@ use deltachat::key::preconfigure_keypair;
use deltachat::message::MsgId;
use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
use deltachat::stock_str::StockMessage;
use deltachat::transport::add_pseudo_transport;
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::*;
use deltachat::{accounts::Accounts, log::LogExt};
@@ -159,24 +160,6 @@ pub unsafe extern "C" fn dc_context_open(
.unwrap_or(0)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_context_change_passphrase(
context: *mut dc_context_t,
passphrase: *const libc::c_char,
) -> libc::c_int {
if context.is_null() {
eprintln!("ignoring careless call to dc_context_change_passphrase()");
return 0;
}
let ctx = unsafe { &*context };
let passphrase = to_string_lossy(passphrase);
block_on(ctx.change_passphrase(passphrase))
.context("dc_context_change_passphrase() failed")
.log_err(ctx)
.is_ok() as libc::c_int
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_context_is_open(context: *mut dc_context_t) -> libc::c_int {
if context.is_null() {
@@ -414,6 +397,21 @@ pub unsafe extern "C" fn dc_configure(context: *mut dc_context_t) {
spawn_configure(ctx.clone());
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_add_pseudo_transport(
context: *mut dc_context_t,
addr: *const libc::c_char,
) {
if context.is_null() {
eprintln!("ignoring careless call to dc_add_pseudo_transport()");
return;
}
let ctx = unsafe { &*context };
let addr = to_string_lossy(addr);
block_on(add_pseudo_transport(ctx, &addr)).log_err(ctx).ok();
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_is_configured(context: *mut dc_context_t) -> libc::c_int {
if context.is_null() {
@@ -477,7 +475,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,
@@ -499,6 +496,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,
@@ -544,7 +542,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(_)
@@ -573,7 +570,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
@@ -619,7 +617,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(_)
@@ -648,7 +645,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, .. }
@@ -714,7 +712,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)
@@ -750,7 +747,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(),
@@ -2397,7 +2395,7 @@ pub unsafe extern "C" fn dc_get_securejoin_qr_svg(
chat_id: u32,
) -> *mut libc::c_char {
if context.is_null() {
eprintln!("ignoring careless call to generate_verification_qr()");
eprintln!("ignoring careless call to dc_get_securejoin_qr_svg()");
return "".strdup();
}
let ctx = unsafe { &*context };
@@ -3963,18 +3961,6 @@ pub unsafe extern "C" fn dc_contact_get_display_name(
ffi_contact.contact.get_display_name().strdup()
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_contact_get_name_n_addr(
contact: *mut dc_contact_t,
) -> *mut libc::c_char {
if contact.is_null() {
eprintln!("ignoring careless call to dc_contact_get_name_n_addr()");
return "".strdup();
}
let ffi_contact = unsafe { &*contact };
ffi_contact.contact.get_name_n_addr().strdup()
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_contact_get_profile_image(
contact: *mut dc_contact_t,
@@ -4030,13 +4016,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)]
@@ -4049,27 +4035,6 @@ pub unsafe extern "C" fn dc_contact_is_blocked(contact: *mut dc_contact_t) -> li
ffi_contact.contact.is_blocked() as libc::c_int
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_contact_is_verified(contact: *mut dc_contact_t) -> libc::c_int {
if contact.is_null() {
eprintln!("ignoring careless call to dc_contact_is_verified()");
return 0;
}
let ffi_contact = unsafe { &*contact };
if block_on(ffi_contact.contact.is_verified(&ffi_contact.context))
.context("is_verified failed")
.log_err(&ffi_contact.context)
.unwrap_or_default()
{
// Return value is essentially a boolean,
// but we return 2 for true for backwards compatibility.
2
} else {
0
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_contact_is_bot(contact: *mut dc_contact_t) -> libc::c_int {
if contact.is_null() {
@@ -4088,22 +4053,6 @@ pub unsafe extern "C" fn dc_contact_is_key_contact(contact: *mut dc_contact_t) -
unsafe { (*contact).contact.is_key_contact() as libc::c_int }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn dc_contact_get_verifier_id(contact: *mut dc_contact_t) -> u32 {
if contact.is_null() {
eprintln!("ignoring careless call to dc_contact_get_verifier_id()");
return 0;
}
let ffi_contact = unsafe { &*contact };
let verifier_contact_id = block_on(ffi_contact.contact.get_verifier_id(&ffi_contact.context))
.context("failed to get verifier")
.log_err(&ffi_contact.context)
.unwrap_or_default()
.unwrap_or_default()
.unwrap_or_default();
verifier_contact_id.to_u32()
}
// dc_lot_t
pub type dc_lot_t = lot::Lot;
@@ -4750,12 +4699,17 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
accounts: *const dc_accounts_t,
timeout_in_seconds: u64,
) -> libc::c_int {
if accounts.is_null() || timeout_in_seconds <= 2 {
if accounts.is_null() {
eprintln!("ignoring careless call to dc_accounts_background_fetch()");
return 0;
}
let accounts = unsafe { &*accounts };
if timeout_in_seconds <= 2 {
eprintln!("ignoring careless call to dc_accounts_background_fetch(): timeout too small");
block_on(accounts.read()).emit_event(EventType::AccountsBackgroundFetchDone);
return 0;
}
let background_fetch_future = {
let lock = block_on(accounts.read());
lock.background_fetch(Duration::from_secs(timeout_in_seconds))
-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.60.0"
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.60.0"
"version": "2.63.0-dev"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.60.0"
version = "2.63.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2024"
license = "MPL-2.0"
+61 -11
View File
@@ -278,9 +278,26 @@ impl CommandApi {
/// Performs a background fetch for all accounts in parallel with a timeout.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout.
/// For an account with IO stopped, the scheduler is paused
/// and every transport is fetched concurrently on a dedicated connection.
/// The account is done as soon as one transport received messages, the others stop.
/// Only one batch of messages is fetched per transport this way,
/// so a larger backlog is left to the next call or to started IO.
///
/// For an account with IO running, IMAP IDLE is interrupted on every transport
/// and the account is done once every transport is.
///
/// The call never waits for outgoing messages and never triggers sending them itself.
/// Received messages may still queue replies, securejoin handshakes for example,
/// which go out only while IO is running.
/// Use `is_sending_finished()` to tell whether the outgoing queue is empty.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout,
/// and immediately if another background fetch is already running.
/// Process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
/// without forgetting to create a generic notification if no message was fetched.
/// The event carries no data identifying the call it belongs to,
/// so it marks your own call only if no concurrent background fetch is happening.
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
let future = {
let lock = self.accounts.read().await;
@@ -291,6 +308,11 @@ impl CommandApi {
Ok(())
}
/// Stops an ongoing `background_fetch()` call, making it return early
/// without waiting for the remaining transports or for the timeout.
///
/// The `AccountsBackgroundFetchDone` event is emitted as usual.
/// Does nothing if no background fetch is running.
async fn stop_background_fetch(&self) -> Result<()> {
self.accounts.read().await.stop_background_fetch();
Ok(())
@@ -524,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.
@@ -825,6 +859,8 @@ impl CommandApi {
/// Get QR code text that will offer a [SecureJoin](https://securejoin.delta.chat/) invitation.
///
/// To reset invitations, pass the link to `set_config_from_qr()`.
///
/// If `chat_id` is a group chat ID, SecureJoin QR code for the group is returned.
/// If `chat_id` is unset, setup contact QR code is returned.
async fn get_chat_securejoin_qr_code(
@@ -838,20 +874,19 @@ impl CommandApi {
Ok(qr)
}
/// Get QR code (text and SVG) that will offer a Setup-Contact or Verified-Group invitation.
/// Get QR code (text and SVG) that will offer a SecureJoin invitation.
/// The QR code is compatible to the OPENPGP4FPR format
/// so that a basic fingerprint comparison also works e.g. with OpenKeychain.
///
/// The scanning device will pass the scanned content to `checkQr()` then;
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
/// an out-of-band-verification can be joined using `secure_join()`
/// the securejoin protocol can be started using `secure_join()`
///
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
///
/// chat_id: If set to a group-chat-id,
/// the Verified-Group-Invite protocol is offered in the QR code;
/// works for protected groups as well as for normal groups.
/// If not set, the Setup-Contact protocol is offered in the QR code.
/// the SecureJoin QR code for the group is returned.
/// If not set, the setup contact QR code is returned.
/// See https://securejoin.delta.chat/ for details about both protocols.
///
/// return format: `[code, svg]`
@@ -867,7 +902,7 @@ impl CommandApi {
Ok((qr, svg))
}
/// Continue a Setup-Contact or Verified-Group-Invite protocol
/// Continue the SecureJoin protocol
/// started on another device with `get_chat_securejoin_qr_code_svg()`.
/// This function is typically called when `check_qr()` returns
/// type=AskVerifyContact or type=AskVerifyGroup.
@@ -950,8 +985,6 @@ impl CommandApi {
/// If the group is already _promoted_ (any message was sent to the group),
/// all group members are informed by a special status message that is sent automatically by this function.
///
/// If the group has group protection enabled, only verified contacts can be added to the group.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
async fn add_contact_to_chat(
&self,
@@ -1773,7 +1806,7 @@ impl CommandApi {
/// Get encryption info for a contact.
/// Get a multi-line encryption info, containing your fingerprint and the
/// fingerprint of the contact, used e.g. to compare the fingerprints for a simple out-of-band verification.
/// fingerprint of the contact, used e.g. to compare the fingerprints out-of-band.
async fn get_contact_encryption_info(
&self,
account_id: u32,
@@ -2038,6 +2071,14 @@ impl CommandApi {
Ok(())
}
/// Waits until all transports are idle or failed and no background work is left.
/// Never returns unless I/O is started. Must ONLY be used by tests.
async fn wait_for_all_work_done(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.wait_for_all_work_done().await;
Ok(())
}
/// Get the current connectivity, i.e. whether the device is connected to the IMAP server.
/// One of:
/// - DC_CONNECTIVITY_NOT_CONNECTED (1000): Show e.g. the string "Not connected" or a red dot
@@ -2775,6 +2816,15 @@ impl CommandApi {
.map(JsonrpcAppSource::from_core_type),
)
}
/// Returns true if all accounts have empty outgoing message queue.
///
/// This API is intended to be used by UIs
/// to request that operating system does not put the application in background
/// while there are still outgoing messages that are not sent out.
async fn is_sending_finished(&self) -> Result<bool> {
self.accounts.read().await.is_sending_finished().await
}
}
// Helper functions (to prevent code duplication)
@@ -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 -45
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 {
@@ -17,7 +39,6 @@ pub struct ContactObject {
id: u32,
name: String,
profile_image: Option<String>, // BLOBS
name_and_addr: String,
is_blocked: bool,
/// Is the contact a key contact.
@@ -31,40 +52,9 @@ pub struct ContactObject {
/// e.g. if we just scanned the fingerprint from a QR code.
e2ee_avail: bool,
/// True if the contact
/// can be added to protected chats
/// because SELF and contact have verified their fingerprints in both directions.
///
/// See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information.
is_verified: bool,
/// The contact ID that verified a contact.
///
/// As verifier may be unknown,
/// use [`Self::is_verified`]/`Contact.isVerified` to check if a contact can be added to a protected chat.
///
/// UI should display the information in the contact's profile as follows:
///
/// - If `verifierId` != 0,
/// display text "Introduced by ..."
/// with the name of the contact.
/// Prefix the text by a green checkmark.
///
/// - If `verifierId` == 0 and `isVerified` != 0,
/// display "Introduced" prefixed by a green checkmark.
///
/// - if `verifierId` == 0 and `isVerified` == 0,
/// display nothing
///
/// This contains the contact ID of the verifier.
/// If it is `DC_CONTACT_ID_SELF`, we verified the contact ourself.
/// If it is None/Null, we don't have verifier information or
/// the contact is not verified.
verifier_id: Option<u32>,
/// the contact's last seen timestamp
last_seen: i64,
was_seen_recently: bool,
freshness: ContactFreshness,
/// If the contact is a bot.
is_bot: bool,
@@ -79,14 +69,6 @@ impl ContactObject {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None,
};
let is_verified = contact.is_verified(context).await?;
let verifier_id = contact
.get_verifier_id(context)
.await?
.flatten()
.map(|contact_id| contact_id.to_u32());
Ok(ContactObject {
address: contact.get_addr().to_owned(),
color: color_int_to_hex_string(contact.get_color()),
@@ -96,14 +78,11 @@ impl ContactObject {
id: contact.id.to_u32(),
name: contact.get_name().to_owned(),
profile_image, //BLOBS
name_and_addr: contact.get_name_n_addr(),
is_blocked: contact.is_blocked(),
is_key_contact: contact.is_key_contact(),
e2ee_avail: contact.e2ee_avail(context).await?,
is_verified,
verifier_id,
last_seen: contact.last_seen(),
was_seen_recently: contact.was_seen_recently(),
freshness: contact.get_freshness().into(),
is_bot: contact.is_bot(),
})
}
+21 -10
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 {
@@ -337,8 +343,7 @@ pub enum EventType {
contact_id: u32,
/// Progress as:
/// 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself."
/// (Bob has verified alice and waits until Alice does the same for him)
/// 400=vg-/vc-request-with-auth sent, typically shown as "introducing myself."
/// 1000=vg-member-added/vc-contact-confirm received
progress: u16,
},
@@ -394,11 +399,15 @@ pub enum EventType {
msg_id: u32,
},
/// Tells that the Background fetch was completed (or timed out).
/// This event acts as a marker, when you reach this event you can be sure
/// that all events emitted during the background fetch were processed.
/// Tells that a background fetch call is done:
/// the fetch completed, timed out, was stopped or was not started.
///
/// This event is only emitted by the account manager
/// For the call that started the fetch, this event acts as a marker:
/// all events emitted during the fetch were processed once it is reached.
/// A call made while another background fetch is running gets the event immediately,
/// and the running fetch keeps emitting events until its own marker.
///
/// This event is only emitted by the account manager.
AccountsBackgroundFetchDone,
/// Inform that set of chats or the order of the chats in the chatlist has changed.
///
@@ -493,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 },
@@ -513,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,
+2 -17
View File
@@ -7,7 +7,7 @@ use typescript_type_def::TypeDef;
#[serde(rename = "Qr", rename_all = "camelCase")]
#[serde(tag = "kind")]
pub enum QrObject {
/// Ask the user whether to verify the contact.
/// Ask the user whether to start chatting with the contact.
///
/// If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`].
AskVerifyContact {
@@ -61,23 +61,13 @@ pub enum QrObject {
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Contact fingerprint is verified.
/// Contact fingerprint matches.
///
/// Ask the user if they want to start chatting.
FprOk {
/// 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,
+1 -1
View File
@@ -12,7 +12,7 @@ pub struct JsonrpcReaction {
emoji: String,
/// Emoji frequency.
count: usize,
count: u32,
/// True if we reacted with this emoji.
is_from_self: bool,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.60.0"
version = "2.63.0-dev"
license = "MPL-2.0"
edition = "2024"
repository = "https://github.com/chatmail/core"
+4 -10
View File
@@ -259,19 +259,13 @@ async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()
let contact = Contact::get_by_id(context, *contact_id).await?;
let name = contact.get_display_name();
let addr = contact.get_addr();
let verified_str = if contact.is_verified(context).await? {
" √"
} else {
""
};
let line = format!(
"{}{} <{}>",
"{} <{}>",
if !name.is_empty() {
name
} else {
"<name unset>"
},
verified_str,
if !addr.is_empty() { addr } else { "addr unset" }
);
@@ -1119,11 +1113,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let contact_id = ContactId::new(arg1.parse()?);
let contact = Contact::get_by_id(&context, contact_id).await?;
let name_n_addr = contact.get_name_n_addr();
let name = contact.get_display_name();
let addr = contact.get_addr();
let mut res = format!(
"Contact info for: {}:\nIcon: {}\n",
name_n_addr,
"Contact info for: {name} ({addr}):\nIcon: {}\n",
match contact.get_profile_image(&context).await? {
Some(image) => image.to_str().unwrap().to_string(),
None => "NoIcon".to_string(),
+1 -2
View File
@@ -173,7 +173,7 @@ const DB_COMMANDS: [&str; 10] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 39] = [
const CHAT_COMMANDS: [&str; 38] = [
"listchats",
"listarchived",
"start-realtime",
@@ -182,7 +182,6 @@ const CHAT_COMMANDS: [&str; 39] = [
"createchat",
"creategroup",
"createbroadcast",
"createprotected",
"addmember",
"removemember",
"groupname",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.60.0"
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)
@@ -150,9 +162,10 @@ class Account:
return transports
def bring_online(self):
"""Start I/O and wait until IMAP becomes IDLE."""
"""Start I/O, wait until all transports became IDLE and drop the events seen so far."""
self.start_io()
self.wait_for_event(EventType.IMAP_INBOX_IDLE)
self._rpc.wait_for_all_work_done(self.id)
self.clear_all_events()
def create_contact(self, obj: Union[int, str, Contact, "Account"], name: Optional[str] = None) -> Contact:
"""Create a new Contact or return an existing one.
@@ -271,7 +284,7 @@ class Account:
return Contact(self, SpecialContactId.SELF)
@property
def device_contact(self) -> Chat:
def device_contact(self) -> Contact:
"""Account's device contact."""
return Contact(self, SpecialContactId.DEVICE)
@@ -359,7 +372,7 @@ class Account:
return Chat(self, chat_id)
def secure_join(self, qrdata: str) -> Chat:
"""Continue a Setup-Contact or Verified-Group-Invite protocol started on another device.
"""Continue the SecureJoin protocol started on another device.
The function returns immediately and the handshake runs in background, sending
and receiving several messages.
@@ -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"
@@ -70,6 +69,7 @@ class EventType(str, Enum):
SELFAVATAR_CHANGED = "SelfavatarChanged"
WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate"
WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted"
ACCOUNTS_BACKGROUND_FETCH_DONE = "AccountsBackgroundFetchDone"
CHATLIST_CHANGED = "ChatlistChanged"
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
ACCOUNTS_CHANGED = "AccountsChanged"
@@ -48,6 +48,13 @@ class DeltaChat:
"""Stop ongoing background fetch."""
self.rpc.stop_background_fetch()
def wait_for_event(self, event_type=None) -> AttrDict:
"""Wait until the next account manager event and return it."""
while True:
next_event = AttrDict(self.rpc.wait_for_event(0))
if event_type is None or next_event.kind == event_type:
return next_event
def maybe_network(self) -> None:
"""Indicate that the network conditions might have changed."""
self.rpc.maybe_network()
@@ -67,3 +74,7 @@ class DeltaChat:
def stop_sending_locations(self) -> None:
"""Stop sending locations to all chats."""
return self.rpc.stop_sending_locations()
def is_sending_finished(self) -> bool:
"""Return true if sending queues of all accounts are empty."""
return self.rpc.is_sending_finished()
@@ -121,7 +121,7 @@ class Message:
yield self._rpc.send_webxdc_realtime_advertisement.future(self.account.id, self.id)
@futuremethod
def send_webxdc_realtime_data(self, data) -> None:
def send_webxdc_realtime_data(self, data):
"""Send data to the realtime channel."""
yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data))
@@ -76,7 +76,7 @@ class RPCAccountFactory:
"""Create a new unconfigured bot."""
return Bot(self.get_unconfigured_account())
def get_credentials(self) -> (str, str):
def get_credentials(self) -> tuple[str, str]:
"""Generate new credentials for chatmail account."""
domain = os.environ["CHATMAIL_DOMAIN"]
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
+14 -7
View File
@@ -22,8 +22,18 @@ ALL = "1:*"
class DirectImap:
"""Internal Python-level IMAP handling."""
def __init__(self, account: Account) -> None:
def __init__(self, account: Account, addr=None, password=None) -> None:
self.account = account
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()
@@ -33,9 +43,9 @@ class DirectImap:
host = self.account.get_config("configured_mail_server")
port = 993
user = self.account.get_config("addr")
user = self.addr
host = user.rsplit("@")[-1]
pw = self.account.get_config("mail_pw")
pw = self.password
ssl_context = ssl.create_default_context()
if host.startswith("_"):
@@ -169,7 +179,7 @@ class DirectImap:
self.conn.append(bytes(msg, encoding="ascii"), folder)
def get_uid_by_message_id(self, message_id) -> str:
msgs = [msg.uid for msg in self.conn.fetch(AND(header=Header("MESSAGE-ID", message_id)))]
msgs = [msg.uid for msg in self.conn.fetch(AND(header=Header("MESSAGE-ID", message_id)), mark_seen=False)]
if len(msgs) == 0:
raise Exception("Did not find message " + message_id + ", maybe you forgot to select the correct folder?")
return msgs[0]
@@ -178,9 +188,6 @@ class DirectImap:
class IdleManager:
def __init__(self, direct_imap) -> None:
self.direct_imap = direct_imap
# fetch latest messages before starting idle so that it only
# returns messages that arrive anew
self.direct_imap.conn.fetch("1:*")
self.direct_imap.conn.idle.start()
def check(self, timeout=None) -> list[bytes]:
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from deltachat_rpc_client import Account, EventType, const
from deltachat_rpc_client import EventType, const
if TYPE_CHECKING:
from deltachat_rpc_client.pytestplugin import RPCAccountFactory
@@ -144,7 +144,7 @@ def test_download_on_demand(acf: RPCAccountFactory, rpcdata) -> None:
wait_for_chatlist_specific_item(alice, chat_id)
def get_multi_account_test_setup(acf: RPCAccountFactory) -> [Account, Account, Account]:
def get_multi_account_test_setup(acf: RPCAccountFactory) -> list:
alice, bob = acf.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
+89 -6
View File
@@ -25,13 +25,12 @@ def test_qr_setup_contact(acf, alice_and_remote_bob, version) -> None:
remote_eval(f"bob.secure_join({qr_code!r})")
alice.wait_for_securejoin_inviter_success()
# Test that Alice verified Bob's profile.
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
assert alice_contact_bob_snapshot.e2ee_avail
remote_eval("bob.wait_for_securejoin_joiner_success()")
# Test that Bob verified Alice's profile.
# The old core still marks Alice as verified, so the handshake is unchanged on the wire.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
# Test that Bob can also scan a QR code
@@ -56,13 +55,12 @@ def test_qr_setup_contact_multitransport(acf, alice_and_remote_bob, version) ->
remote_eval(f"bob.secure_join({qr_code!r})")
alice.wait_for_securejoin_inviter_success()
# Test that Alice verified Bob's profile.
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
assert alice_contact_bob_snapshot.e2ee_avail
remote_eval("bob.wait_for_securejoin_joiner_success()")
# Test that Bob verified Alice's profile.
# The old core still marks Alice as verified, so the handshake is unchanged on the wire.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
@@ -137,3 +135,88 @@ def test_keyupdate_against_core_2_48_march_2026(acf, alice_and_remote_bob, repla
if replace_relay:
remote_eval("bob_contact_alice.create_chat().send_text('hello after replacement')")
assert alice.wait_for_incoming_msg().get_snapshot().text == "hello after replacement"
class LocalSide:
"""Alice on the core under test."""
def __init__(self, account, peer_contact):
self.account = account
self.peer_contact = peer_contact
self.chat = None
def make_qr(self, invite):
if invite == "group":
self.chat = self.account.create_group("Group")
elif invite == "broadcast":
self.chat = self.account.create_broadcast("Channel")
return self.chat.get_qr_code() if self.chat else self.account.get_qr_code()
def join(self, qr):
self.account.secure_join(qr)
def wait_inviter(self):
self.account.wait_for_securejoin_inviter_success()
def wait_joiner(self):
self.account.wait_for_securejoin_joiner_success()
def send_text(self, text):
chat = self.chat or self.peer_contact.create_chat()
chat.send_text(text)
def next_text(self):
return self.account.wait_for_incoming_msg().get_snapshot().text
class RemoteSide:
"""Bob on the other core, driven through remote_eval."""
def __init__(self, remote_eval):
self.remote_eval = remote_eval
self.chat = None
def make_qr(self, invite):
if invite == "contact":
return self.remote_eval("bob.get_qr_code()")
create = {"group": "bob.create_group('Group')", "broadcast": "bob.create_broadcast('Channel')"}[invite]
self.remote_eval(f"locals().update(chat={create})")
self.chat = "chat"
return self.remote_eval("chat.get_qr_code()")
def join(self, qr):
self.remote_eval(f"bob.secure_join({qr!r})")
def wait_inviter(self):
self.remote_eval("bob.wait_for_securejoin_inviter_success()")
def wait_joiner(self):
self.remote_eval("bob.wait_for_securejoin_joiner_success()")
def send_text(self, text):
chat = self.chat or "bob_contact_alice.create_chat()"
self.remote_eval(f"{chat}.send_text({text!r})")
def next_text(self):
return self.remote_eval("bob.wait_for_incoming_msg().get_snapshot().text")
@pytest.mark.parametrize("version", ["2.48.0"])
@pytest.mark.parametrize("invite", ["contact", "group", "broadcast"])
@pytest.mark.parametrize("remote_invites", [False, True], ids=["local-invites", "remote-invites"])
def test_securejoin_invite(alice_and_remote_bob, version, invite, remote_invites):
"""Every invite link type works with either core as the inviter."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
local = LocalSide(alice, alice_contact_bob)
remote = RemoteSide(remote_eval)
inviter, joiner = (remote, local) if remote_invites else (local, remote)
joiner.join(inviter.make_qr(invite))
joiner.wait_joiner()
inviter.wait_inviter()
# Group and broadcast joins add an info message first.
if invite != "contact":
joiner.next_text()
inviter.send_text("hello")
assert joiner.next_text() == "hello"
@@ -1,3 +1,4 @@
import time
import urllib.parse
import pytest
@@ -7,6 +8,28 @@ from deltachat_rpc_client.const import ChatType, DownloadState
from deltachat_rpc_client.rpc import JsonRpcError
def alice_with_two_transports_and_bob(acf):
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
return alice, alice.create_chat(bob), bob.create_chat(alice)
def messages_with_text(chat, text):
return [msg for msg in chat.get_messages() if msg.get_snapshot().text == text]
def wait_for_imap_message(imap):
while not imap.get_all_messages():
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
@@ -35,10 +58,9 @@ def test_add_second_address(acf) -> None:
def test_change_address(acf) -> None:
"""Test Alice configuring a second transport and setting it as a primary one."""
"""Test Alice configuring a second transport and removing the first one."""
alice, bob = acf.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr")
bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob)
@@ -48,22 +70,14 @@ def test_change_address(acf) -> None:
sender_addr1 = msg1.sender.get_snapshot().address
alice.stop_io()
old_alice_addr = alice.get_config("configured_addr")
old_alice_addr = alice.list_transports()[0]["addr"]
alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr in alice_vcard
qr = acf.get_account_qr()
alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"]
with pytest.raises(JsonRpcError):
# Cannot use the address that is not
# configured for any transport.
alice.set_config("configured_addr", bob_addr)
# Load old address so it is cached.
assert alice.get_config("configured_addr") == old_alice_addr
alice.set_config("configured_addr", new_alice_addr)
# Make sure that setting `configured_addr` invalidated the cache.
assert alice.get_config("configured_addr") == new_alice_addr
alice.delete_transport(old_alice_addr)
alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr not in alice_vcard
@@ -81,6 +95,25 @@ def test_change_address(acf) -> None:
assert sender_addr2 == new_alice_addr
def test_remove_transport_keep_messages(acf) -> None:
"""Test that deleting current sending transport keeps queued messages."""
alice, bob = acf.get_online_accounts(2)
qr = acf.get_account_qr()
alice.add_transport_from_qr(qr)
alice.stop_io()
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello!")
new_alice_addr = alice.list_transports()[1]["addr"]
alice.delete_transport(alice.list_transports()[0]["addr"])
alice.start_io()
bob_msg = bob.wait_for_incoming_msg().get_snapshot()
assert bob_msg.sender.get_snapshot().address == new_alice_addr
def test_download_on_demand(acf, rpcdata) -> None:
alice, bob = acf.get_online_accounts(2)
alice.set_config("download_limit", "1")
@@ -192,10 +225,6 @@ def test_transport_sync_new_as_primary(acf, log) -> None:
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport2["addr"])
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert ac1_clone.get_config("configured_addr") == transport1["addr"]
log.section("ac1_clone receives a message via the new transport")
ac1_chat = ac1.create_chat(bob)
@@ -251,11 +280,10 @@ def test_message_info_imap_urls(acf) -> None:
alice, bob = acf.get_online_accounts(2)
qr = acf.get_account_qr()
for i in range(3):
for _ in range(3):
alice.add_transport_from_qr(qr)
# Wait for all transports to go IDLE after adding each one.
for _ in range(i + 1):
alice.bring_online()
alice.bring_online()
# Enable multi-device mode so messages are not deleted immediately.
alice.set_config("bcc_self", "1")
@@ -287,14 +315,7 @@ def test_message_info_imap_urls(acf) -> None:
def test_remove_primary_transport(acf, log) -> None:
"""Test that after removing the primary relay, Alice can still receive messages."""
alice, bob = acf.get_online_accounts(2)
qr = acf.get_account_qr()
alice.add_transport_from_qr(qr)
alice.bring_online()
bob_chat = bob.create_chat(alice)
alice.create_chat(bob)
alice, alice_chat, bob_chat = alice_with_two_transports_and_bob(acf)
log.section("Alice sets up second transport")
[transport1, transport2] = alice.list_transports()
@@ -313,7 +334,7 @@ def test_remove_primary_transport(acf, log) -> None:
msg2 = alice.wait_for_incoming_msg().get_snapshot()
assert msg2.text == "Hello again!"
assert msg2.chat.get_basic_snapshot().chat_type == ChatType.SINGLE
assert msg2.chat == alice.create_chat(bob)
assert msg2.chat == alice_chat
def test_qr_works_after_removing_primary_transport(acf, log) -> None:
@@ -344,3 +365,44 @@ def test_qr_works_after_removing_primary_transport(acf, log) -> None:
bob.secure_join(chat_qr)
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
def test_background_fetch_from_second_transport(acf, direct_imap, dc):
alice, alice_chat, bob_chat = alice_with_two_transports_and_bob(acf)
[transport1, transport2] = alice.list_transports()
assert alice.get_config("configured_addr") == transport1["addr"]
alice.stop_io()
bob_chat.send_text("hello")
imap1 = direct_imap(alice, transport1["addr"], transport1["password"])
wait_for_imap_message(direct_imap(alice, transport2["addr"], transport2["password"]))
wait_for_imap_message(imap1)
# Leave the message on the second transport only.
imap1.delete("1:*")
dc.background_fetch(300)
assert len(messages_with_text(alice_chat, "hello")) == 1
def test_background_fetch_no_duplicates(acf, direct_imap, dc):
alice, alice_chat, bob_chat = alice_with_two_transports_and_bob(acf)
alice.stop_io()
bob_chat.send_text("hello")
for transport in alice.list_transports():
wait_for_imap_message(direct_imap(alice, transport["addr"], transport["password"]))
dc.background_fetch(300)
assert len(messages_with_text(alice_chat, "hello")) == 1
def test_multitransport_mdn(acf):
"""Test sending an MDN right after configuring two transports."""
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
alice.create_chat(bob)
bob_msg = bob.create_chat(alice).send_text("Hello!")
alice.wait_for_incoming_msg().mark_seen()
assert bob.wait_for_event(EventType.MSG_READ).msg_id == bob_msg.id
+50 -142
View File
@@ -4,10 +4,9 @@ import pytest
from deltachat_rpc_client import Chat, EventType, SpecialContactId
from deltachat_rpc_client.const import ChatType
from deltachat_rpc_client.rpc import JsonRpcError
def test_qr_setup_contact(acf, tmp_path) -> None:
def test_qr_setup_contact(acf) -> None:
alice, bob = acf.get_online_accounts(2)
qr_code = alice.get_qr_code()
@@ -15,38 +14,20 @@ def test_qr_setup_contact(acf, tmp_path) -> None:
alice.wait_for_securejoin_inviter_success()
# Test that Alice verified Bob's profile.
alice_contact_bob = alice.create_contact(bob)
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
assert alice_contact_bob_snapshot.e2ee_avail
bob.wait_for_securejoin_joiner_success()
# Test that Bob verified Alice's profile.
bob_contact_alice = bob.create_contact(alice)
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
assert bob_contact_alice_snapshot.is_verified
# Test that if Bob imports a key,
# backwards verification is not lost
# because default key is not changed.
logging.info("Bob 2 is created")
bob2 = acf.new_configured_account()
bob2.export_self_keys(tmp_path)
logging.info("Bob tries to import a key")
# Importing a second key is not allowed.
with pytest.raises(JsonRpcError):
bob.import_self_keys(tmp_path)
assert bob.get_config("key_id") == "1"
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
assert bob_contact_alice_snapshot.is_verified
assert bob_contact_alice_snapshot.e2ee_avail
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()
@@ -62,6 +43,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()
@@ -81,26 +63,24 @@ def test_qr_securejoin(acf):
ac.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
bob.wait_for_securejoin_joiner_success()
# Test that Alice verified Bob's profile.
alice_contact_bob = alice.create_contact(bob)
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
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."
# Test that Bob verified Alice's profile.
bob_contact_alice = bob.create_contact(alice)
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
assert bob_contact_alice_snapshot.is_verified
assert bob_contact_alice_snapshot.e2ee_avail
# Start second Alice device.
# Alice observes securejoin protocol and verifies Bob on second device.
# Alice observes the securejoin protocol on the second device.
alice2.start_io()
alice2.wait_for_securejoin_inviter_success()
alice2_contact_bob = alice2.create_contact(bob)
alice2_contact_bob_snapshot = alice2_contact_bob.get_snapshot()
assert alice2_contact_bob_snapshot.is_verified
assert alice2_contact_bob_snapshot.e2ee_avail
# The QR code token is synced, so alice2 must be able to handle join requests.
logging.info("Fiona joins the group via alice2")
@@ -151,9 +131,9 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
assert snapshot2.chat_id == chat.id
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
# Check that the chat partner is verified.
# Check that the chat partner's key is known.
contact_snapshot = contact.get_snapshot()
assert contact_snapshot.is_verified
assert contact_snapshot.e2ee_avail
chat = get_broadcast(ac)
chat_msgs = chat.get_messages()
@@ -167,17 +147,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!"
@@ -242,7 +218,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!"
@@ -350,8 +326,8 @@ def test_setup_contact_resetup(acf) -> None:
bob.wait_for_securejoin_joiner_success()
def test_verified_group_member_added_recovery(acf) -> None:
"""Tests verified group recovery by reverifying then removing and adding a member back."""
def test_group_member_added_recovery(acf) -> None:
"""Tests group recovery after a member resets its key."""
ac1, ac2, ac3 = acf.get_online_accounts(3)
logging.info("ac1 creates a group")
@@ -362,11 +338,7 @@ def test_verified_group_member_added_recovery(acf) -> None:
ac2.secure_join(qr_code)
ac2.wait_for_securejoin_joiner_success()
# ac1 has ac2 directly verified.
ac1_contact_ac2 = ac1.create_contact(ac2)
assert ac1_contact_ac2.get_snapshot().verifier_id == SpecialContactId.SELF
logging.info("ac3 joins verified group")
logging.info("ac3 joins the group")
ac3_chat = ac3.secure_join(qr_code)
ac3.wait_for_securejoin_joiner_success()
ac3.wait_for_incoming_msg_event() # Member added
@@ -376,7 +348,7 @@ def test_verified_group_member_added_recovery(acf) -> None:
logging.info("ac2 logs in on a new device")
ac2 = acf.resetup_account(ac2)
logging.info("ac2 reverifies with ac3")
logging.info("ac2 scans ac3's QR code again")
qr_code = ac3.get_qr_code()
ac2.secure_join(qr_code)
ac2.wait_for_securejoin_joiner_success()
@@ -416,14 +388,6 @@ def test_verified_group_member_added_recovery(acf) -> None:
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Works again!"
ac1_contact_ac2 = ac1.create_contact(ac2)
ac1_contact_ac3 = ac1.create_contact(ac3)
ac1_contact_ac2_snapshot = ac1_contact_ac2.get_snapshot()
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not ac1_contact_ac2_snapshot.is_verified
assert ac1_contact_ac2_snapshot.verifier_id != ac1_contact_ac3.id
def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
"""Regression test for
@@ -431,13 +395,13 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
"""
ac1, ac2, ac3, ac4 = acf.get_online_accounts(4)
logging.info("ac3: verify with ac2")
logging.info("ac3: set up contact with ac2")
qr_code = ac2.get_qr_code()
ac3.secure_join(qr_code)
ac2.wait_for_securejoin_inviter_success()
# in order for ac2 to have pending bobstate with a verified group
# we first create a fully joined verified group, and then start
# in order for ac2 to have pending bobstate with a group
# we first create a fully joined group, and then start
# joining a second time but interrupt it, to create pending bob state
logging.info("ac1: create a group that ac2 fully joins")
@@ -446,7 +410,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
ac2.secure_join(qr_code)
ac1.wait_for_securejoin_inviter_success()
# ensure ac1 can write and ac2 receives messages in verified chat
# ensure ac1 can write and ac2 receives messages in the chat
ch1.send_text("ac1 says hello")
while 1:
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
@@ -459,11 +423,11 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
ac1.remove()
logging.info("ac2 now has pending bobstate but ac1 is shutoff")
# we meanwhile expect ac3/ac2 verification started in the beginning to have completed
assert ac3.create_contact(ac2).get_snapshot().is_verified
assert ac2.create_contact(ac3).get_snapshot().is_verified
# we meanwhile expect the ac3/ac2 setup-contact started in the beginning to have completed
assert ac3.create_contact(ac2).get_snapshot().e2ee_avail
assert ac2.create_contact(ac3).get_snapshot().e2ee_avail
logging.info("ac3: create a verified group VG with ac2")
logging.info("ac3: create a group VG with ac2")
vg = ac3.create_group("ac3-created")
vg.add_contact(ac3.create_contact(ac2))
@@ -486,7 +450,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
def test_qr_new_group_unblocked(acf):
"""Regression test for a bug introduced in core v1.113.0.
ac2 scans a verified group QR code created by ac1.
ac2 scans a group QR code created by ac1.
This results in creation of a blocked single chat with ac1 on ac2,
but ac1 contact is not blocked on ac2.
Then ac1 creates a group, adds ac2 there and promotes it by sending a message.
@@ -513,13 +477,13 @@ def test_qr_new_group_unblocked(acf):
@pytest.mark.skip(reason="AEAP is disabled for now")
def test_aeap_flow_verified(acf):
def test_aeap_flow(acf):
"""Test that a new address is added to a contact when it changes its address."""
ac1, ac2 = acf.get_online_accounts(2)
addr, password = acf.get_credentials()
logging.info("ac1: create verified-group QR, ac2 scans and joins")
logging.info("ac1: create group QR, ac2 scans and joins")
chat = ac1.create_group("hello")
qr_code = chat.get_qr_code()
logging.info("ac2: start QR-code based join-group protocol")
@@ -536,11 +500,9 @@ def test_aeap_flow_verified(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()
@@ -555,65 +517,16 @@ def test_aeap_flow_verified(acf):
assert addr in [contact.get_snapshot().address for contact in msg_in_2_snapshot.chat.get_contacts()]
def test_gossip_verification(acf) -> None:
alice, bob, carol = acf.get_online_accounts(3)
# Bob verifies Alice.
qr_code = alice.get_qr_code()
bob.secure_join(qr_code)
bob.wait_for_securejoin_joiner_success()
# Bob verifies Carol.
qr_code = carol.get_qr_code()
bob.secure_join(qr_code)
bob.wait_for_securejoin_joiner_success()
bob_contact_alice = bob.create_contact(alice, "Alice")
bob_contact_carol = bob.create_contact(carol, "Carol")
carol_contact_alice = carol.create_contact(alice, "Alice")
logging.info("Bob creates an Autocrypt group")
bob_group_chat = bob.create_group("Autocrypt Group")
bob_group_chat.add_contact(bob_contact_alice)
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Autocrypt group")
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Autocrypt group"
assert snapshot.show_padlock
# Group propagates verification using Autocrypt-Gossip header.
carol_contact_alice_snapshot = carol_contact_alice.get_snapshot()
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not carol_contact_alice_snapshot.is_verified
logging.info("Bob creates a Securejoin group")
bob_group_chat = bob.create_group("Securejoin Group")
bob_group_chat.add_contact(bob_contact_alice)
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Securejoin group")
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Securejoin group"
assert snapshot.show_padlock
# Securejoin propagates verification.
carol_contact_alice_snapshot = carol_contact_alice.get_snapshot()
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not carol_contact_alice_snapshot.is_verified
def test_securejoin_after_contact_resetup(acf) -> None:
"""
Regression test for a bug that prevented joining verified group with a QR code
if the group is already created and contains
a contact with inconsistent (Autocrypt and verified keys exist but don't match) key state.
Regression test for a bug that prevented joining a group with a QR code
if the group already contains a contact with the same address as the inviter,
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 protected group with ac1.
# ac3 creates a group with ac1.
ac3_chat = ac3.create_group("Group")
# ac1 joins ac3 group.
@@ -623,31 +536,27 @@ 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 verifies ac1
# ac2 sets up contact with ac1
qr_code = ac1.get_qr_code()
ac2.secure_join(qr_code)
ac2.wait_for_securejoin_joiner_success()
# ac1 is verified for ac2.
ac2_contact_ac1 = ac2.create_contact(ac1, "")
assert ac2_contact_ac1.get_snapshot().is_verified
assert ac2_contact_ac1.get_snapshot().e2ee_avail
# ac1 resetups the account.
ac1 = acf.resetup_account(ac1)
ac2_contact_ac1 = ac2.create_contact(ac1, "")
assert not ac2_contact_ac1.get_snapshot().is_verified
# ac1 goes offline.
ac1.remove()
# Scanning a QR code results in creating an unprotected group with an inviter.
# In this case inviter is ac1 which has an inconsistent key state.
# Normally inviter becomes verified as a result of Securejoin protocol
# and then the group chat becomes verified when "Member added" is received,
# but in this case ac1 is offline and this Securejoin process will never finish.
# Scanning a QR code creates a group with the inviter, here ac1.
# Normally the securejoin protocol
# would complete and "Member added" would arrive,
# but ac1 is offline so it never finishes.
logging.info("ac2 scans ac1 QR code, this is not expected to finish")
ac2.secure_join(ac1_qr_code)
@@ -664,16 +573,15 @@ def test_securejoin_after_contact_resetup(acf) -> None:
ac2_chat = snapshot.chat
assert len(ac2_chat.get_contacts()) == 3
# ac1 is still "not verified" for ac2 due to inconsistent state.
assert not ac2_contact_ac1.get_snapshot().is_verified
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")
logging.info("Bob joins verified group")
logging.info("Bob joins the group")
qr_code = alice_chat.get_qr_code()
bob_chat = bob.secure_join(qr_code)
@@ -682,11 +590,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)
+54 -13
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)
@@ -421,7 +421,6 @@ def test_dont_move_sync_msgs(acf, direct_imap):
addr, password = acf.get_credentials()
ac1 = acf.get_unconfigured_account()
ac1.set_config("bcc_self", "1")
ac1.set_config("fix_is_chatmail", "1")
ac1.add_or_update_transport({"addr": addr, "password": password})
ac1.start_io()
ac1_direct_imap = direct_imap(ac1)
@@ -605,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
@@ -623,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
@@ -1188,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()
@@ -1200,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."
@@ -1354,6 +1347,22 @@ def test_background_fetch(acf, dc):
break
def test_background_fetch_does_not_wait_for_sending(dc, acf):
alice, bob = acf.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
alice.stop_io()
text = "x" * 200_000
for _ in range(50):
alice_chat_bob.send_text(text)
assert not dc.is_sending_finished()
alice.start_io()
dc.background_fetch(50)
dc.wait_for_event(EventType.ACCOUNTS_BACKGROUND_FETCH_DONE)
assert not dc.is_sending_finished()
def test_message_exists(acf):
ac1, ac2 = acf.get_online_accounts(2)
chat = ac1.create_chat(ac2)
@@ -1435,3 +1444,35 @@ def test_large_message(acf, rpcdata) -> None:
assert msg.id == msgs_changed_event.msg_id
snapshot = msg.get_snapshot()
assert snapshot.text == "Hello World, this message is bigger than 5 bytes"
def test_is_sending_finished(dc, acf) -> None:
alice, bob = acf.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
bob_chat_alice = bob.create_chat(alice)
assert dc.is_sending_finished()
alice_chat_bob.send_text("Hello!")
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert dc.is_sending_finished()
alice.stop_io()
bob.stop_io()
bob_chat_alice.send_text("Hello back!")
alice_chat_bob.send_text("Hello again!")
assert not dc.is_sending_finished()
alice.start_io()
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert not dc.is_sending_finished()
bob.start_io()
bob.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert dc.is_sending_finished()
+1 -2
View File
@@ -1,9 +1,8 @@
[package]
name = "deltachat-rpc-server"
version = "2.60.0"
version = "2.63.0-dev"
description = "DeltaChat JSON-RPC server"
edition = "2024"
readme = "README.md"
license = "MPL-2.0"
keywords = ["deltachat", "chat", "openpgp", "email", "encryption"]
@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.60.0"
"version": "2.63.0-dev"
}
+3
View File
@@ -69,8 +69,11 @@ skip = [
{ name = "derive_more-impl", version = "1.0.0" },
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },
{ name = "foldhash", version = "0.1.5" },
{ name = "getrandom", version = "0.2.12" },
{ name = "getrandom", version = "0.3.3" },
{ name = "hashbrown", version = "0.15.4" },
{ name = "hashbrown", version = "0.16.1" },
{ name = "heck", version = "0.4.1" },
{ name = "http", version = "0.2.12" },
{ name = "hybrid-array", version = "0.2.3" },
+69 -18
View File
@@ -72,8 +72,7 @@ CREATE TABLE contacts (
-- empty string for "address-contacts".
fingerprint TEXT NOT NULL DEFAULT '',
-- ID of the contact that has "introduced" us to this contact
-- by sharing the key with a verified attribute or in a "verified" chat.
-- Unused. Was the ID of the contact that introduced this contact's key.
verifier INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX contacts_index1 ON contacts (name COLLATE NOCASE);
@@ -131,9 +130,7 @@ CREATE TABLE chats (
-- 0 means the timer is disabled.
ephemeral_timer INTEGER,
-- Deprecated, but still used to send Chat-Verified headers
-- for existing protected chats.
-- All new chats are created as "not protected".
-- Unused. Was 1 for protected chats.
protected INTEGER DEFAULT 0,
gossiped_timestamp INTEGER DEFAULT 0, -- deprecated 2025-04-08, replaced with gossip_timestamp table
@@ -400,14 +397,51 @@ CREATE TABLE bobstate (
chat_id INTEGER NOT NULL
);
CREATE TABLE smtp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc724_mid TEXT NOT NULL, -- Message-ID
mime TEXT NOT NULL, -- SMTP payload
msg_id INTEGER NOT NULL, -- ID of the message in `msgs` table
recipients TEXT NOT NULL, -- List of recipients separated by space
retries INTEGER NOT NULL DEFAULT 0 -- Number of failed attempts to send the message
);
CREATE TABLE smtp2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL, -- Display name to put into the From field.
rfc724_mid TEXT NOT NULL, -- Message-ID
-- Unencrypted payload with some headers.
mime BLOB NOT NULL,
-- True if Autocrypt header should be added before sending.
should_attach_pubkey INTEGER NOT NULL,
-- True if OpenPGP-encrypted message may use compression.
should_compress INTEGER NOT NULL,
-- True if encrypted message should be signed.
should_sign INTEGER NOT NULL,
-- ID of the message in `msgs` table
msg_id INTEGER NOT NULL,
-- Space-separated recipient addresses.
recipients TEXT NOT NULL,
-- Space-separated addresses the message was sent to.
sent_to TEXT NOT NULL DEFAULT '',
-- If true, copy should be sent to self in addition to the recipient list.
--
-- For encrypted messages copy is sent to all addresses.
-- For unencrypted messages, copy is sent to the From address only.
bcc_self INTEGER NOT NULL,
-- True if the message is encrypted.
-- If true, at most one of the shared_secret or encryption_fingerprints should be non-empty.
-- If false, both must be empty.
is_encrypted INTEGER NOT NULL,
-- Shared secret if the message is to be encrypted symmetrically.
shared_secret TEXT NOT NULL DEFAULT '',
-- Space-separated fingerprints of the keys the message should be encrypted to.
encryption_fingerprints TEXT NOT NULL DEFAULT '',
retries INTEGER NOT NULL DEFAULT 0 -- Number of failed attempts to send the message
) STRICT;
CREATE TABLE smtp_mdns (
msg_id INTEGER NOT NULL, -- id of the message in msgs table which requested MDN (DEPRECATED 2024-06-21)
@@ -596,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 (
@@ -646,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)
@@ -693,13 +734,13 @@ CREATE TABLE stats_securejoin_uipaths(
) STRICT;
CREATE TABLE stats_securejoin_invites(
already_existed INTEGER NOT NULL,
already_verified INTEGER NOT NULL,
already_verified INTEGER NOT NULL, -- unused, always 0
type TEXT NOT NULL
) STRICT;
CREATE TABLE stats_msgs(
chattype INTEGER PRIMARY KEY,
verified INTEGER NOT NULL DEFAULT 0,
unverified_encrypted INTEGER NOT NULL DEFAULT 0,
verified INTEGER NOT NULL DEFAULT 0, -- unused, always 0
unverified_encrypted INTEGER NOT NULL DEFAULT 0, -- counts all encrypted messages
unencrypted INTEGER NOT NULL DEFAULT 0,
only_to_self INTEGER NOT NULL DEFAULT 0,
last_counted_msg_id INTEGER NOT NULL DEFAULT 0
@@ -781,3 +822,13 @@ CREATE TABLE sending_domains(
domain TEXT PRIMARY KEY,
dkim_works INTEGER DEFAULT 0
);
-- Replaced with smtp2.
CREATE TABLE smtp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc724_mid TEXT NOT NULL, -- Message-ID
mime TEXT NOT NULL, -- SMTP payload
msg_id INTEGER NOT NULL, -- ID of the message in `msgs` table
recipients TEXT NOT NULL, -- List of recipients separated by space
retries INTEGER NOT NULL DEFAULT 0 -- Number of failed attempts to send the message
);
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.60.0"
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"
-11
View File
@@ -71,17 +71,6 @@ class Contact:
"""Unblock this contact. Messages from this contact will be retrieved (again)."""
return lib.dc_block_contact(self.account._dc_context, self.id, False)
def is_verified(self) -> bool:
"""Return True if the contact is verified."""
return lib.dc_contact_is_verified(self._dc_contact) == 2
def get_verifier(self, contact) -> Optional["Contact"]:
"""Return the address of the contact that verified the contact."""
verifier_id = lib.dc_contact_get_verifier_id(contact._dc_contact)
if verifier_id == 0:
return None
return Contact(self.account, verifier_id)
def get_profile_image(self) -> Optional[str]:
"""Get contact profile image.
+3 -6
View File
@@ -104,11 +104,11 @@ class DirectImap:
def get_all_messages(self) -> List[MailMessage]:
assert not self._idling
return list(self.conn.fetch())
return list(self.conn.fetch(mark_seen=False))
def get_unread_messages(self) -> List[str]:
assert not self._idling
return [msg.uid for msg in self.conn.fetch(AND(seen=False))]
return [msg.uid for msg in self.conn.fetch(AND(seen=False), mark_seen=False)]
def mark_all_read(self):
messages = self.get_unread_messages()
@@ -183,7 +183,7 @@ class DirectImap:
self.conn.append(bytes(msg, encoding="ascii"), folder)
def get_uid_by_message_id(self, message_id) -> str:
msgs = [msg.uid for msg in self.conn.fetch(AND(header=Header("MESSAGE-ID", message_id)))]
msgs = [msg.uid for msg in self.conn.fetch(AND(header=Header("MESSAGE-ID", message_id)), mark_seen=False)]
if len(msgs) == 0:
raise Exception("Did not find message " + message_id + ", maybe you forgot to select the correct folder?")
return msgs[0]
@@ -193,9 +193,6 @@ class IdleManager:
def __init__(self, direct_imap) -> None:
self.direct_imap = direct_imap
self.log = direct_imap.account.log
# fetch latest messages before starting idle so that it only
# returns messages that arrive anew
self.direct_imap.conn.fetch("1:*")
self.direct_imap.conn.idle.start()
def check(self, timeout=None) -> List[bytes]:
+5
View File
@@ -11,6 +11,9 @@ import random
from queue import Queue
from typing import Callable, Dict, List, Optional
from .capi import lib
from .cutil import as_dc_charpointer
import pytest
from _pytest._code import Source
@@ -366,6 +369,7 @@ class ACFactory:
ac.open(passphrase)
acname = ac._logid
addr = f"{acname}@offline.org"
lib.dc_add_pseudo_transport(ac._dc_context, as_dc_charpointer(addr))
ac.update_config(
{
"configured_addr": addr,
@@ -374,6 +378,7 @@ class ACFactory:
)
self._preconfigure_key(ac)
self._acsetup.init_logging(ac)
assert ac.is_configured(), "Pseudo configured account should look like if it is configured"
return ac
def new_online_configuring_account(self, cloned_from=None, **kwargs) -> Account:
+11 -27
View File
@@ -1,7 +1,5 @@
import time
import deltachat as dc
class TestGroupStressTests:
def test_group_many_members_add_leave_remove(self, acfactory, lp):
@@ -63,9 +61,9 @@ class TestGroupStressTests:
assert msg.is_encrypted()
def test_qr_verified_group_and_chatting(acfactory, lp):
def test_qr_group_join_and_chatting(acfactory, lp):
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
lp.sec("ac1: create group QR, ac2 scans and joins")
chat1 = ac1.create_group_chat("hello")
qr = chat1.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
@@ -86,15 +84,11 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
msg_out = chat1.send_text("hello")
assert msg_out.is_encrypted()
lp.sec("ac2: read message and check that it's a verified chat")
lp.sec("ac2: read message and check that it is encrypted")
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
assert msg.is_encrypted()
lp.sec("ac2: Check that ac2 verified ac1")
ac2_ac1_contact = ac2.get_contacts()[0]
assert ac2.get_self_contact().get_verifier(ac2_ac1_contact).id == dc.const.DC_CONTACT_ID_SELF
lp.sec("ac2: send message and let ac1 read it")
chat2.send_text("world")
msg = ac1._evtracker.wait_next_incoming_message()
@@ -109,23 +103,13 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000)
lp.sec("ac1: add ac3 to verified group")
lp.sec("ac1: add ac3 to the group")
chat1.add_contact(ac3)
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.is_encrypted()
assert msg.is_system_message()
assert not msg.error
lp.sec("ac2: Check that ac1 verified ac3 for ac2")
ac2_ac1_contact = ac2.get_contacts()[0]
assert ac2.get_self_contact().get_verifier(ac2_ac1_contact).id == dc.const.DC_CONTACT_ID_SELF
for ac2_contact in chat2.get_contacts():
if ac2_contact == ac2_ac1_contact or ac2_contact.id == dc.const.DC_CONTACT_ID_SELF:
continue
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert ac2.get_self_contact().get_verifier(ac2_contact) is None
lp.sec("ac2: send message and let ac3 read it")
chat2.send_text("hi")
# System message about the added member.
@@ -195,10 +179,10 @@ def test_ephemeral_timer(acfactory, lp):
assert chat1.get_ephemeral_timer() == 0
def test_see_new_verified_member_after_going_online(acfactory, tmp_path, lp):
def test_see_new_member_after_going_online(acfactory, tmp_path, lp):
"""The test for the bug #3836:
- Alice has two devices, the second is offline.
- Alice creates a verified group and sends a QR invitation to Bob.
- Alice creates a group and sends a QR invitation to Bob.
- Bob joins the group and sends a message there. Alice sees it.
- Alice's second devices goes online, but doesn't see Bob in the group.
"""
@@ -215,7 +199,7 @@ def test_see_new_verified_member_after_going_online(acfactory, tmp_path, lp):
ac1_offl.import_self_keys(str(dir))
ac1_offl.stop_io()
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
lp.sec("ac1: create group QR, ac2 scans and joins")
chat = ac1.create_group_chat("hello")
qr = chat.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
@@ -242,12 +226,12 @@ def test_see_new_verified_member_after_going_online(acfactory, tmp_path, lp):
assert msg_in.get_sender_contact().addr == ac2_addr
def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp):
def test_use_new_group_after_going_online(acfactory, data, tmp_path, lp):
"""Another test for the bug #3836:
- Bob has two devices, the second is offline.
- Alice creates a verified group and sends a QR invitation to Bob.
- Alice creates a group and sends a QR invitation to Bob.
- Bob joins the group.
- Bob's second devices goes online, but sees a contact request instead of the verified group.
- Bob's second devices goes online, but sees a contact request instead of the group.
- The "member added" message is not a system message but a plain text message.
- Bob's second device doesn't display the Alice's avatar (bug #5354).
- Sending a message fails as the key is missing -- message info says "proper enc-key for <Alice>
@@ -269,7 +253,7 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp
avatar_path = data.get_path("d.png")
ac1.set_avatar(avatar_path)
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
lp.sec("ac1: create group QR, ac2 scans and joins")
chat = ac1.create_group_chat("hello")
qr = chat.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
+2 -9
View File
@@ -796,9 +796,8 @@ def test_send_and_receive_image(acfactory, lp, data):
def test_qr_email_capitalization(acfactory, lp):
"""Regression test for a bug
that resulted in failure to propagate verification
when the database already contained the contact with a different email address capitalization.
"""Tests joining a group via QR code
when the database already contains a contact with a different email address capitalization.
"""
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
@@ -822,13 +821,7 @@ def test_qr_email_capitalization(acfactory, lp):
ac2.qr_join_chat(qr)
ac1._evtracker.wait_next_incoming_message()
# ac1 should see both ac3 and ac2 as verified.
assert len(ac1_chat.get_contacts()) == 3
# Until we reset verifications and then send the _verified header,
# the verification of ac2 is not gossiped here:
for contact in ac1_chat.get_contacts():
is_ac2 = contact.addr == ac2.get_config("addr")
assert contact.is_verified() != is_ac2
def test_set_get_contact_avatar(acfactory, data, lp):
+6 -7
View File
@@ -115,7 +115,6 @@ class TestOfflineContact:
assert contact1.addr == "some1@example.org"
assert contact1.display_name == "some1"
assert not contact1.is_blocked()
assert not contact1.is_verified()
def test_get_blocked(self, acfactory):
ac1 = acfactory.get_pseudo_configured_account()
@@ -133,7 +132,7 @@ class TestOfflineContact:
def test_create_self_contact(self, acfactory):
ac1 = acfactory.get_pseudo_configured_account()
contact1 = ac1.create_contact(ac1.get_config("addr"))
contact1 = ac1.create_contact(ac1.get_config("configured_addr"))
assert contact1.id == 1
def test_get_contacts_and_delete(self, acfactory):
@@ -224,7 +223,7 @@ class TestOfflineChat:
ac2 = acfactory.get_pseudo_configured_account()
chat = ac1.create_group_chat(name="title1")
contact = chat.add_contact(ac2)
assert contact.addr == ac2.get_config("addr")
assert contact.addr == ac2.get_config("configured_addr")
assert contact.name == ac2.get_config("displayname")
assert contact.account == ac1
chat.remove_contact(ac2)
@@ -457,7 +456,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("addr")
assert contact2.addr == ac_contact.get_config("configured_addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
@@ -553,7 +552,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("addr")
assert contact2.addr == ac_contact.get_config("configured_addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
@@ -605,7 +604,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("addr")
assert contact2.addr == ac_contact.get_config("configured_addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
@@ -622,7 +621,7 @@ class TestOfflineChat:
contacts = ac2.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.addr == ac_contact.get_config("addr")
assert contact2.addr == ac_contact.get_config("configured_addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2 + E2EE_INFO_MSGS
+1 -1
View File
@@ -1 +1 @@
2026-09-11
2026-09-22
-18
View File
@@ -596,24 +596,6 @@ and e.g. simply search for the line starting with `EMAIL`
in order to get the email address.
# Verifications
Keys obtained using [SecureJoin](https://securejoin.readthedocs.io) protocol
and corresponding contacts
are considered "verified".
As an extension to `Autocrypt-Gossip` header,
chatmail clients can add `_verified=1` attribute
(underscore marks the attribute as non-critical)
to indicate that they have the gossiped key
and the corresponding contact marked as verified.
When receiving such `Autocrypt-Gossip` header
in a message signed by a verified key,
chatmail clients mark the gossiped key
as indirectly verified.
# Miscellaneous
Messengers SHOULD use the header `In-Reply-To` as usual.
+44 -45
View File
@@ -26,6 +26,7 @@ use crate::events::{Event, EventEmitter, EventType, Events};
use crate::location;
use crate::log::warn;
use crate::push::PushSubscriber;
use crate::smtp;
use crate::stock_str::StockStrings;
/// Account manager, that can handle multiple accounts in a single place.
@@ -168,9 +169,7 @@ impl Accounts {
.with_push_subscriber(self.push_subscriber.clone())
.build()
.await?;
// Try to open without a passphrase,
// but do not return an error if account is passphare-protected.
ctx.open("".to_string()).await?;
ctx.open().await?;
self.accounts.insert(account_config.id, ctx);
self.emit_event(EventType::AccountsChanged);
@@ -442,7 +441,12 @@ impl Accounts {
interrupt_receiver: Option<Receiver<()>>,
) {
let Some(interrupt_receiver) = interrupt_receiver else {
// Nothing to do if we got no interrupt receiver.
// Another background fetch is already running.
// Emit the event anyway so that a caller waiting for it does not hang.
events.emit(Event {
id: 0,
typ: EventType::AccountsBackgroundFetchDone,
});
return;
};
if let Err(_err) = tokio::time::timeout(
@@ -476,9 +480,15 @@ impl Accounts {
/// return immediately even before the timeout expiration
/// or finishing fetching.
///
/// Pending outgoing messages are not waited for and not triggered.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end,
/// process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
/// If another background fetch is already running,
/// nothing is fetched and the event is emitted immediately.
/// The event carries no data identifying the call it belongs to,
/// so it only safely refers to your call if no concurrent background fetch is happening.
///
/// Returns a future that resolves when background fetch is done,
/// but does not capture `&self`.
@@ -509,6 +519,20 @@ impl Accounts {
)
}
/// Returns true if there are no pending messages for sending.
///
/// This is intended to be used by UIs to request not moving the app to background
/// when there are messages left in the queue.
pub async fn is_sending_finished(&self) -> Result<bool> {
let accounts: Vec<Context> = self.accounts.values().cloned().collect();
for account in accounts {
if !smtp::queue::is_empty(&account).await? {
return Ok(false);
}
}
Ok(true)
}
/// Interrupts ongoing background_fetch() call,
/// making it return early.
///
@@ -795,9 +819,7 @@ impl Config {
.build()
.await
.with_context(|| format!("failed to create context from file {dbfile:?}"))?;
// Try to open without a passphrase,
// but do not return an error if account is passphare-protected.
ctx.open("".to_string()).await?;
ctx.open().await?;
accounts.insert(account_config.id, ctx);
}
@@ -1225,50 +1247,27 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_encrypted_account() -> Result<()> {
let dir = tempfile::tempdir().context("failed to create tempdir")?;
let p: PathBuf = dir.path().join("accounts");
async fn test_background_fetch_emits_done_when_already_running() -> Result<()> {
let dir = tempfile::tempdir()?;
let writable = true;
let mut accounts = Accounts::new(p.clone(), writable)
.await
.context("failed to create accounts manager")?;
let accounts = Accounts::new(dir.path().join("accounts"), writable).await?;
let event_emitter = accounts.get_event_emitter();
assert_eq!(accounts.accounts.len(), 0);
let account_id = accounts
.add_closed_account()
.await
.context("failed to add closed account")?;
let account = accounts
.get_selected_account()
.context("failed to get account")?;
assert_eq!(account.id, account_id);
let passphrase_set_success = account
.open("foobar".to_string())
.await
.context("failed to set passphrase")?;
assert!(passphrase_set_success);
drop(accounts);
let timeout = std::time::Duration::from_secs(3);
let first = accounts.background_fetch(timeout);
let second = accounts.background_fetch(timeout);
tokio::join!(first, second);
let writable = false;
let accounts = Accounts::new(p.clone(), writable)
.await
.context("failed to create second accounts manager")?;
let account = accounts
.get_selected_account()
.context("failed to get account")?;
assert_eq!(account.is_open().await, false);
// Try wrong passphrase.
assert_eq!(account.open("barfoo".to_string()).await?, false);
assert_eq!(account.open("".to_string()).await?, false);
assert_eq!(account.open("foobar".to_string()).await?, true);
assert_eq!(account.is_open().await, true);
let mut done = 0;
while let Ok(event) = event_emitter.try_recv() {
if matches!(event.typ, EventType::AccountsBackgroundFetchDone) {
done += 1;
}
}
assert_eq!(done, 2);
Ok(())
}
/// Tests that accounts share stock string translations.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accounts_share_translations() -> Result<()> {
-34
View File
@@ -43,13 +43,6 @@ pub struct Aheader {
pub addr: String,
pub public_key: SignedPublicKey,
pub prefer_encrypt: EncryptPreference,
/// Whether `_verified` attribute is present.
///
/// `_verified` attribute is an extension to `Autocrypt-Gossip`
/// header that is used to tell that the sender
/// marked this key as verified.
pub verified: bool,
}
impl fmt::Display for Aheader {
@@ -58,12 +51,6 @@ impl fmt::Display for Aheader {
if self.prefer_encrypt == EncryptPreference::Mutual {
write!(fmt, " prefer-encrypt=mutual;")?;
}
// TODO After we reset all existing verifications,
// we want to start sending the _verified attribute
// if self.verified {
// write!(fmt, " _verified=1;")?;
// }
// adds a whitespace every 78 characters, this allows
// email crate to wrap the lines according to RFC 5322
// (which may insert a linebreak before every whitespace)
@@ -114,8 +101,6 @@ impl Aheader {
.and_then(|raw| EncryptPreference::new(&raw).ok())
.unwrap_or_default();
let verified = attributes.remove("_verified").is_some();
// Autocrypt-Level0: unknown attributes starting with an underscore can be safely ignored
// Autocrypt-Level0: unknown attribute, treat the header as invalid
if attributes.keys().any(|k| !k.starts_with('_')) {
@@ -126,7 +111,6 @@ impl Aheader {
addr,
public_key,
prefer_encrypt,
verified,
})
}
}
@@ -145,7 +129,6 @@ mod tests {
assert_eq!(h.addr, "me@mail.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual);
assert_eq!(h.verified, false);
Ok(())
}
@@ -243,7 +226,6 @@ mod tests {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::Mutual,
verified: false
}
)
.contains("prefer-encrypt=mutual;")
@@ -259,7 +241,6 @@ mod tests {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::NoPreference,
verified: false
}
)
.contains("prefer-encrypt")
@@ -273,24 +254,9 @@ mod tests {
addr: "TeSt@eXaMpLe.cOm".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::Mutual,
verified: false
}
)
.contains("test@example.com")
);
// We don't send the _verified header yet:
assert!(
!format!(
"{}",
Aheader {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::NoPreference,
verified: true
}
)
.contains("_verified")
);
}
}
+36
View File
@@ -388,4 +388,40 @@ mod tests {
Ok(())
}
// some systems may not get the update URL from the relays, but use a specific one.
// this is esp. useful when the app binary is not signed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_app_versions_no_url() -> Result<()> {
let dir = tempfile::tempdir().unwrap();
let p: PathBuf = dir.path().join("accounts");
let writable = true;
let mut accounts = Accounts::new(p.clone(), writable).await.unwrap();
let account_id1 = accounts.add_account().await?;
let json = r##"{
"clients": [
{
"clientId": "deltawin",
"sources": [
{
"sourceId": "windows-3.11",
"versionInteger": 300,
"versionString": "3.00.000"
}
]
}
]
}"##;
mockup_app_versions(&accounts, account_id1, 1, json).await;
let version = get_app_version(&accounts, "deltawin", "windows-3.11")
.await?
.unwrap();
assert_eq!(version.version_integer, 300);
assert_eq!(version.version_string, "3.00.000");
assert_eq!(version.download_url, ""); // no url given results in default empty string
Ok(())
}
}
+144 -45
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,87 @@ 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.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>> {
@@ -93,10 +169,7 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
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,13 +186,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 param = login_param_from_host(host);
let res = crate::configure::configure(context, &param, skip_network).await;
let mark_as_autorelay = true;
let param = login_param_from_host(host, mark_as_autorelay);
let res = SILENT_PROGRESS
.scope((), configure(context, &param, skip_network))
.await;
if let Err(e) = res {
warn!(
context,
@@ -134,37 +207,63 @@ 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)
}
pub(crate) fn login_param_from_host(host: &str) -> EnteredLoginParam {
/// 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);
let addr = username + "@" + host;
let addr = addr_normalize(&addr);
// `mark_as_autorelay` is a temporary precaution hack
// while introducing onboarding on multiple community relays from a list:
// though relay operators were asked to get on that list, unexpected things can happen,
// and they want to return to allow only manual onboarding.
// this is possible by failing on `password_len == 23`.
// 22 * log2(26 * 2 + 10) = 130 bits of entropy
let password = Alphanumeric.sample_string(rng, 22);
let password = Alphanumeric.sample_string(rng, if mark_as_autorelay { 23 } else { 22 });
EnteredLoginParam {
addr,
+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",
+1 -1
View File
@@ -386,7 +386,7 @@ impl<'a> BlobObject<'a> {
let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
let exceeds_max_bytes = nr_bytes > max_bytes as u64;
let jpeg_quality = 75;
let jpeg_quality = 75; // 70-80 is the sweet spot of quality vs. bytes/pixel. if one wants to spend more bytes in quality, better increase resolution
let ofmt = match fmt {
ImageFormat::Png if !exceeds_max_bytes => ImageOutputFormat::Png,
ImageFormat::Jpeg => {
+5 -5
View File
@@ -508,17 +508,17 @@ async fn test_recode_image_rgba_png_to_jpeg() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_huge_jpg() {
let bytes = include_bytes!("../../test-data/image/screenshot.jpg");
let bytes = include_bytes!("../../test-data/image/screenshot-2560x1440.jpg");
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "0",
bytes,
extension: "jpg",
has_exif: true,
original_width: 1920,
original_height: 1080,
compressed_width: 1704,
compressed_height: 959,
original_width: 2560,
original_height: 1440,
compressed_width: 2344,
compressed_height: 1319,
..Default::default()
}
.test()
+13 -46
View File
@@ -42,7 +42,7 @@ const RINGING_SECONDS: i64 = 120;
const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg;
const CALL_ENDED_TIMESTAMP: Param = Param::Arg4;
const STUN_PORT: u16 = 3478;
const TURN_PORT: u16 = 3478;
/// Set if incoming call was ended explicitly
/// by the other side before we accepted it.
@@ -658,12 +658,9 @@ pub(crate) async fn create_ice_servers_from_metadata(
Ok((expiration_timestamp, ice_servers))
}
/// STUN or TURN server with unresolved DNS name.
/// TURN server with unresolved DNS name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum UnresolvedIceServer {
/// STUN server.
Stun { hostname: String, port: u16 },
/// TURN server with the username and password.
Turn {
hostname: String,
@@ -688,28 +685,6 @@ pub(crate) async fn resolve_ice_servers(
for unresolved_ice_server in unresolved_ice_servers {
match unresolved_ice_server {
UnresolvedIceServer::Stun { hostname, port } => {
match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
Ok(addrs) => {
let urls: Vec<String> = addrs
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let stun_server = IceServer {
urls,
username: None,
credential: None,
};
result.push(stun_server);
}
Err(err) => {
warn!(
context,
"Failed to resolve STUN {hostname}:{port}: {err:#}."
);
}
}
}
UnresolvedIceServer::Turn {
hostname,
port,
@@ -742,25 +717,17 @@ pub(crate) async fn resolve_ice_servers(
/// Creates JSON with ICE servers when no TURN servers are known.
pub(crate) fn create_fallback_ice_servers() -> Vec<UnresolvedIceServer> {
// Do not use public STUN server from https://stunprotocol.org/.
// It changes the hostname every year
// (e.g. stunserver2025.stunprotocol.org
// which was previously stunserver2024.stunprotocol.org)
// because of bandwidth costs:
// <https://github.com/jselbie/stunserver/issues/50>
vec![
UnresolvedIceServer::Stun {
hostname: "nine.testrun.org".to_string(),
port: STUN_PORT,
},
UnresolvedIceServer::Turn {
hostname: "turn.delta.chat".to_string(),
port: STUN_PORT,
username: "public".to_string(),
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
},
]
// As long as we can't rely on most chat profiles
// having a multi-relay setup and offering TURN servers,
// fall back to a service run by Delta Chat developers
// which ensures that no IP addresses or other metadata is
// persistently logged.
vec![UnresolvedIceServer::Turn {
hostname: "turn.delta.chat".to_string(),
port: TURN_PORT,
username: "public".to_string(),
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
}]
}
/// Returns JSON with ICE servers.
+10
View File
@@ -779,3 +779,13 @@ async fn test_end_text_call() -> Result<()> {
Ok(())
}
/// Tests that fallback ice servers just carry the Turn one
#[test]
fn test_fallback_ice_servers() {
let hostnames: Vec<String> = create_fallback_ice_servers()
.into_iter()
.map(|UnresolvedIceServer::Turn { hostname, .. }| hostname)
.collect();
assert_eq!(hostnames, ["turn.delta.chat"]);
}
+97 -195
View File
@@ -11,7 +11,6 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
use chrono::TimeZone;
use deltachat_contact_tools::{ContactAddress, sanitize_bidi_characters, sanitize_single_line};
use humansize::{BINARY, format_size};
use mail_builder::mime::MimePart;
use serde::{Deserialize, Serialize};
use strum_macros::EnumIter;
@@ -27,26 +26,22 @@ use crate::constants::{
use crate::contact::{self, Contact, ContactId, Origin};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc;
use crate::download::{
DownloadState, PRE_MSG_ATTACHMENT_SIZE_THRESHOLD, PRE_MSG_SIZE_WARNING_THRESHOLD,
};
use crate::download::{DownloadState, PRE_MSG_ATTACHMENT_SIZE_THRESHOLD};
use crate::ensure_and_debug_assert_eq;
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key;
use crate::key::{Fingerprint, self_fingerprint};
use crate::location;
use crate::log::{LogExt, warn};
use crate::logged_debug_assert;
use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::mimefactory;
use crate::mimefactory::{MimeFactory, RenderedEmail};
use crate::mimefactory::MimeFactory;
use crate::mimeparser::SystemMessage;
use crate::param::{Param, Params};
use crate::pgp::addresses_from_public_key;
use crate::reaction::broadcast_reactions;
use crate::receive_imf::ReceivedMsg;
use crate::smtp::{self, send_msg_to_smtp};
use crate::smtp::queue::{ToBeQueuedMail, enqueue_mail};
use crate::smtp::send_msg_to_smtp;
use crate::stock_str;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
@@ -336,16 +331,18 @@ impl ChatId {
Ok(chat_id)
}
async fn set_selfavatar_timestamp(self, context: &Context, timestamp: i64) -> Result<()> {
context
.sql
pub(crate) fn set_selfavatar_timestamp(
self,
transaction: &mut rusqlite::Transaction<'_>,
timestamp: i64,
) -> Result<()> {
transaction
.execute(
"UPDATE contacts
SET selfavatar_sent=?
WHERE id IN(SELECT contact_id FROM chats_contacts WHERE chat_id=? AND add_timestamp >= remove_timestamp)",
(timestamp, self),
)
.await?;
) ?;
Ok(())
}
@@ -2686,7 +2683,7 @@ async fn prepare_send_msg(
let skip_fn = |reason: &CantSendReason| match reason {
CantSendReason::ContactRequest => {
// Allow securejoin messages, they are supposed to repair the verification.
// Allow securejoin messages.
// If the chat is a contact request, let the user accept it later.
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
}
@@ -2776,11 +2773,8 @@ async fn render_mime_message_and_pre_message(
context: &Context,
msg: &mut Message,
mimefactory: MimeFactory,
) -> Result<(Option<RenderedEmail>, 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?;
bcc_self: bool,
) -> Result<(Option<ToBeQueuedMail>, ToBeQueuedMail)> {
let needs_pre_message = msg.viewtype.has_file()
&& mimefactory.will_be_encrypted() // unencrypted is likely email, we don't want to spam by sending multiple messages
&& msg
@@ -2797,53 +2791,27 @@ async fn render_mime_message_and_pre_message(
let mut mimefactory_post_msg = mimefactory.clone();
mimefactory_post_msg.set_as_post_message();
let (queued_msg, side_effects) = Box::pin(mimefactory_post_msg.into_queued_mail(context))
.await
.context("Failed to render post-message")?;
let rendered_msg = mimefactory::render_queued_mail(
queued_msg,
&public_key,
&secret_key,
from_addr.clone(),
side_effects,
)?;
let (queued_msg, side_effects) =
Box::pin(mimefactory_post_msg.into_queued_mail(context, bcc_self))
.await
.context("Failed to render post-message")?;
let mut mimefactory_pre_msg = mimefactory;
mimefactory_pre_msg.set_as_pre_message_for(&rendered_msg);
mimefactory_pre_msg.set_as_pre_message_for(&queued_msg.rfc724_mid);
let (queued_pre_msg, pre_side_effects) =
Box::pin(mimefactory_pre_msg.into_queued_mail(context))
Box::pin(mimefactory_pre_msg.into_queued_mail(context, bcc_self))
.await
.context("pre-message failed to render")?;
let rendered_pre_msg = mimefactory::render_queued_mail(
queued_pre_msg,
&public_key,
&secret_key,
from_addr,
pre_side_effects,
)?;
if rendered_pre_msg.message.len() > PRE_MSG_SIZE_WARNING_THRESHOLD {
warn!(
context,
"Pre-message for message {} is larger than expected: {}.",
msg.id,
rendered_pre_msg.message.len()
);
}
Ok((Some(rendered_pre_msg), rendered_msg))
Ok((
Some((queued_pre_msg, pre_side_effects)),
(queued_msg, side_effects),
))
} else {
let (queued_msg, side_effects) = Box::pin(mimefactory.into_queued_mail(context)).await?;
let rendered_msg = mimefactory::render_queued_mail(
queued_msg,
&public_key,
&secret_key,
from_addr,
side_effects,
)?;
let (queued_msg, side_effects) =
Box::pin(mimefactory.into_queued_mail(context, bcc_self)).await?;
Ok((None, rendered_msg))
Ok((None, (queued_msg, side_effects)))
}
}
@@ -2857,6 +2825,8 @@ async fn render_mime_message_and_pre_message(
///
/// The caller has to interrupt SMTP loop or otherwise process new rows.
async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Vec<i64>> {
let now = time();
let cmd = msg.param.get_cmd();
if cmd == SystemMessage::GroupNameChanged || cmd == SystemMessage::GroupDescriptionChanged {
msg.chat_id
@@ -2888,12 +2858,14 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
return Err(err);
}
};
let mut recipients = mimefactory.recipients();
let recipients = mimefactory.recipients();
debug_assert!(!recipients.iter().any(|s| s.is_empty()));
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
// Default Webxdc integrations are hidden messages and must not be sent out:
if (msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden)
// This may happen eg. for groups with only SELF and bcc_self disabled:
|| (!context.get_config_bool(Config::BccSelf).await? && recipients.is_empty())
|| (!bcc_self && recipients.is_empty())
{
info!(
context,
@@ -2906,8 +2878,9 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
return Ok(Vec::new());
}
let (rendered_pre_msg, rendered_msg) =
match render_mime_message_and_pre_message(context, msg, mimefactory).await {
let is_encrypted = mimefactory.will_be_encrypted();
let (queued_pre_msg_pair, queued_msg_pair) =
match render_mime_message_and_pre_message(context, msg, mimefactory, bcc_self).await {
Ok(res) => Ok(res),
Err(err) => {
message::set_msg_failed(context, msg, &err.to_string()).await?;
@@ -2915,29 +2888,13 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
}
}?;
if let (post_msg, Some(pre_msg)) = (&rendered_msg, &rendered_pre_msg) {
info!(
context,
"Message {} sizes: pre-message: {}; post-message: {}.",
msg.id,
format_size(pre_msg.message.len(), BINARY),
format_size(post_msg.message.len(), BINARY),
);
if let Some((pre_msg, _)) = &queued_pre_msg_pair {
msg.pre_rfc724_mid = pre_msg.rfc724_mid.clone();
} else {
info!(
context,
"Message {} will be sent in one shot (no pre- and post-message). Size: {}.",
msg.id,
format_size(rendered_msg.message.len(), BINARY),
);
}
if context.get_config_bool(Config::BccSelf).await? {
smtp::add_self_recipients(context, &mut recipients, rendered_msg.is_encrypted).await?;
}
let (queued_msg, side_effects) = queued_msg_pair;
if needs_encryption && !rendered_msg.is_encrypted {
if needs_encryption && !is_encrypted {
let addr = context.get_config(Config::ConfiguredAddr).await?;
let text = stock_str::unencrypted_email(
context,
@@ -2967,95 +2924,62 @@ async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Ve
);
}
let now = time();
if let Some(last_added_location_timestamp) =
rendered_msg.side_effects.last_added_location_timestamp
{
location::set_kml_sent_timestamp(context, msg.chat_id, last_added_location_timestamp)
.await?;
if let Some(ref side_effects) = side_effects {
msg.subject.clone_from(&side_effects.subject);
}
if rendered_msg.side_effects.avatar_is_attached
|| rendered_pre_msg
.as_ref()
.is_some_and(|msg| msg.side_effects.avatar_is_attached)
{
msg.chat_id
.set_selfavatar_timestamp(context, now)
.await
.context("Failed to set selfavatar timestamp")?;
}
if rendered_msg.is_encrypted {
if is_encrypted {
msg.param.set_int(Param::GuaranteeE2ee, 1);
} else {
msg.param.remove(Param::GuaranteeE2ee);
}
msg.subject.clone_from(&rendered_msg.side_effects.subject);
// Sort the message to the bottom. Employ `msgs_index7` to compute `timestamp`.
context
.sql
.execute(
"
UPDATE msgs SET
timestamp=(
SELECT MAX(timestamp) FROM msgs INDEXED BY msgs_index7 WHERE
-- From `InFresh` to `OutDelivered` inclusive, except `OutDraft`.
state IN(10,13,16,18,20,24,26) AND
hidden IN(0,1) AND
chat_id=? AND
id<=?
),
pre_rfc724_mid=?, subject=?, param=?
WHERE id=?
",
(
msg.chat_id,
msg.id,
&msg.pre_rfc724_mid,
&msg.subject,
msg.param.to_string(),
msg.id,
),
)
.await?;
let trans_fn = |t: &mut rusqlite::Transaction| {
let mut row_ids = Vec::<i64>::new();
if let Some(sync_ids) = rendered_msg.side_effects.sync_ids_to_delete {
t.execute(
&format!("DELETE FROM multi_device_sync WHERE id IN ({sync_ids})"),
(),
)?;
}
if !recipients.is_empty() {
let mut stmt = t.prepare(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
VALUES (?1, ?2, ?3, ?4)",
)?;
let all_recipients = recipients.join(" ");
if let Some(pre_msg) = &rendered_pre_msg {
let row_id = stmt.insert((
&pre_msg.rfc724_mid,
&all_recipients,
&pre_msg.message,
.transaction(|transaction| {
// Sort the message to the bottom. Employ `msgs_index7` to compute `timestamp`.
transaction.execute(
"
UPDATE msgs SET
timestamp=(
SELECT MAX(timestamp) FROM msgs INDEXED BY msgs_index7 WHERE
-- From `InFresh` to `OutDelivered` inclusive, except `OutDraft`.
state IN(10,13,16,18,20,24,26) AND
hidden IN(0,1) AND
chat_id=? AND
id<=?
),
pre_rfc724_mid=?, subject=?, param=?
WHERE id=?
",
(
msg.chat_id,
msg.id,
))?;
row_ids.push(row_id);
&msg.pre_rfc724_mid,
&msg.subject,
msg.param.to_string(),
msg.id,
),
)?;
let mut row_ids = Vec::new();
if let Some((queued_pre_msg, pre_side_effects)) = queued_pre_msg_pair {
let row_id = enqueue_mail(
transaction,
now,
msg.id,
&queued_pre_msg,
pre_side_effects.as_ref(),
)
.context("Failed to enqueue pre-message")?;
row_ids.push(row_id)
}
let row_id = stmt.insert((
&rendered_msg.rfc724_mid,
&all_recipients,
&rendered_msg.message,
msg.id,
))?;
row_ids.push(row_id);
}
Ok(row_ids)
};
context.sql.transaction(trans_fn).await
row_ids.push(
enqueue_mail(transaction, now, msg.id, &queued_msg, side_effects.as_ref())
.context("Failed to enqueue message")?,
);
Ok(row_ids)
})
.await
}
/// Sends a text message to the given chat.
@@ -3994,15 +3918,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);
@@ -4016,6 +3932,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?;
@@ -4064,7 +3985,7 @@ ORDER BY timestamp DESC, id DESC -- final ORDER BY is needed as UNION does not g
(
chat_id,
Viewtype::Webxdc,
constants::N_MSGS_TO_NEW_BROADCAST_MEMBER,
constants::N_MSGS_TO_NEW_BROADCAST_MEMBER as u32,
ContactId::INFO,
),
|row: &rusqlite::Row| Ok(row.get::<_, MsgId>(0)?),
@@ -5004,7 +4925,7 @@ pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Resul
context
.sql
.execute(
r#"INSERT INTO devmsglabels (label) VALUES ("core-welcome-image"), ("core-welcome")"#,
"INSERT INTO devmsglabels (label) VALUES ('core-welcome-image'), ('core-welcome')",
(),
)
.await?;
@@ -5183,7 +5104,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,
@@ -5200,31 +5121,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(())
}
+23 -58
View File
@@ -1702,7 +1702,10 @@ async fn test_shall_attach_selfavatar() -> Result<()> {
add_contact_to_chat(alice, chat_id, contact_id).await?;
assert!(shall_attach_selfavatar(alice, chat_id).await?);
chat_id.set_selfavatar_timestamp(alice, time()).await?;
alice
.sql
.transaction(|transaction| chat_id.set_selfavatar_timestamp(transaction, time()))
.await?;
assert!(!shall_attach_selfavatar(alice, chat_id).await?);
alice.set_config(Config::Selfavatar, None).await?; // setting to None also forces re-sending
@@ -3000,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");
}
@@ -3340,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);
@@ -3756,25 +3745,6 @@ async fn test_broadcast_joining_golden() -> Result<()> {
)
.await;
assert_eq!(
alice_bob_contact
.get_verifier_id(alice)
.await?
.unwrap()
.unwrap(),
ContactId::SELF
);
let bob_alice_contact = bob.add_or_lookup_contact_no_key(alice).await;
assert_eq!(
bob_alice_contact
.get_verifier_id(bob)
.await?
.unwrap()
.unwrap(),
ContactId::SELF
);
Ok(())
}
@@ -4057,10 +4027,9 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
bob1.recv_msg(&member_added).await;
// The single chat should not be visible to the user on any of the devices.
// The contact should be marked as verified.
check_single_chat_is_hidden_and_contact_is_verified(alice, bob0).await;
check_single_chat_is_hidden_and_contact_is_verified(bob0, alice).await;
check_single_chat_is_hidden_and_contact_is_verified(bob1, alice).await;
check_single_chat_is_hidden(alice, bob0).await;
check_single_chat_is_hidden(bob0, alice).await;
check_single_chat_is_hidden(bob1, alice).await;
tcm.section("Alice sends first message to broadcast.");
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
@@ -4087,10 +4056,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
Ok(())
}
async fn check_single_chat_is_hidden_and_contact_is_verified(
t: &TestContext,
contact: &TestContext,
) {
async fn check_single_chat_is_hidden(t: &TestContext, contact: &TestContext) {
let contact = t.add_or_lookup_contact_no_key(contact).await;
if let Some(single_chat) = ChatIdBlocked::lookup_by_contact(t, contact.id)
.await
@@ -4098,7 +4064,6 @@ async fn check_single_chat_is_hidden_and_contact_is_verified(
{
assert_eq!(single_chat.blocked, Blocked::Yes);
}
assert!(contact.is_verified(t).await.unwrap());
}
/// Test that only the owner of the broadcast channel
+32 -88
View File
@@ -18,8 +18,8 @@ use crate::events::EventType;
use crate::log::LogExt;
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{get_abs_path, time};
use crate::transport::{add_pseudo_transport, send_sync_transports, transport_addrs};
use crate::tools::get_abs_path;
use crate::transport::transport_addrs;
use crate::{constants, stats};
/// The available configuration keys.
@@ -42,10 +42,12 @@ use crate::{constants, stats};
#[strum(serialize_all = "snake_case")]
pub enum Config {
/// Deprecated(2026-04).
/// Use ConfiguredAddr, [`crate::login_param::EnteredLoginParam`],
/// or add_transport{from_qr}()/list_transports() instead.
///
/// Email address, used in the `From:` field.
/// Email address used by the deprecated configure() procedure.
///
/// Use add_transport{from_qr}() to configure new transports,
/// Use list_transports() to learn about configured transports,
/// including their addresses.
Addr,
/// Deprecated(2026-04).
@@ -195,9 +197,9 @@ pub enum Config {
#[strum(props(default = "0"))]
DeleteDeviceAfter,
/// The primary email address, used for sending and background fetch.
/// Deprecated(2026-09).
///
/// Device-local, other devices keep their own primary transport.
/// Use ConfiguredLoginParam and list_transports() instead.
ConfiguredAddr,
/// Deprecated(2026-04).
@@ -305,18 +307,6 @@ pub enum Config {
/// True if account is configured.
Configured,
/// Deprecated, we are trying to get rid of this global setting.
/// It is possible to configure a profile with both chatmail relays
/// and classical email servers.
///
/// Most usages in UIs can be replaced by `force_encryption`.
///
/// True if account is a chatmail account.
IsChatmail,
/// True if `IsChatmail` mustn't be autoconfigured. For tests.
FixIsChatmail,
/// True if account is muted.
IsMuted,
@@ -499,11 +489,6 @@ impl Config {
| Self::ForceEncryption,
)
}
/// Whether the config option needs an IO scheduler restart to take effect.
pub(crate) fn needs_io_restart(&self) -> bool {
matches!(self, Config::ConfiguredAddr)
}
}
impl Context {
@@ -549,7 +534,6 @@ impl Context {
// Default values
let val = match key {
Config::ConfiguredInboxFolder => Some("INBOX".to_string()),
Config::Addr => self.get_config_opt(Config::ConfiguredAddr).await?,
_ => key.get_str("default").map(|s| s.to_string()),
};
Ok(val)
@@ -683,10 +667,6 @@ impl Context {
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
Self::check_config(key, value)?;
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
};
if key == Config::StatsSending {
let old_value = self.get_config(key).await?;
let old_value = bool_from_config(old_value.as_deref());
@@ -766,63 +746,28 @@ impl Context {
bail!("Cannot unset configured_addr");
};
if !self.is_configured().await? {
info!(
self,
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
);
add_pseudo_transport(self, addr).await?;
self.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(addr))
.await?;
} else {
self.sql
.transaction(|transaction| {
if transaction.query_row(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(addr,),
|row| {
let res: i64 = row.get(0)?;
Ok(res)
},
)? == 0
{
bail!("Address does not belong to any transport.");
}
transaction.execute(
"UPDATE config SET value=? WHERE keyname='configured_addr'",
(addr,),
)?;
self.sql
.transaction(|transaction| {
if transaction.query_row(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(addr,),
|row| {
let res: i64 = row.get(0)?;
Ok(res)
},
)? == 0
{
bail!("Address does not belong to any transport.");
}
transaction.execute(
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('configured_addr', ?)",
(addr,),
)?;
// The timestamp must strictly increase because
// other devices ignore the row update otherwise,
// and contacts only adopt the re-signed key
// if its signature timestamp increases.
transaction
.execute(
"UPDATE transports
SET add_timestamp=MAX(?, add_timestamp+1)
WHERE addr=?",
(time(), addr),
)
.context(
"Failed to update add_timestamp for the new primary transport",
)?;
// Clean up SMTP queue.
//
// The messages in the queue have a different
// From address so we cannot send them over
// the new SMTP transport.
transaction.execute("DELETE FROM smtp", ())?;
Ok(())
})
.await?;
// Invalidate the cache so the sync message cannot read a stale primary address.
self.sql.uncache_raw_config("configured_addr").await;
send_sync_transports(self).await?;
}
Ok(())
})
.await?;
self.sql.uncache_raw_config("configured_addr").await;
}
_ => {
self.sql.set_raw_config(key.as_ref(), value).await?;
@@ -917,8 +862,7 @@ impl Context {
.any(|a| addr_cmp(addr, a)))
}
/// Sets `primary_new` as the new primary self address and saves the old
/// primary address (if exists) as a secondary address.
/// Sets `primary_new` as the address used for sending.
///
/// This should only be used by test code and during configure.
#[cfg(test)] // AEAP is disabled, but there are still tests for it
@@ -940,7 +884,7 @@ impl Context {
.await
}
/// Returns the primary self address.
/// 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)
+120 -48
View File
@@ -31,7 +31,7 @@ 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;
@@ -40,27 +40,30 @@ use crate::transport::{
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.
@@ -321,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
@@ -343,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
@@ -437,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
@@ -469,7 +529,7 @@ pub(crate) async fn configure(
Ok::<(), anyhow::Error>(())
});
progress!(ctx, 600);
emit_progress(ctx, 600);
// Configure IMAP
@@ -483,23 +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);
let is_configured = ctx.is_configured().await?;
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
if imap_session.is_chatmail() {
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
} else if !is_configured {
// Reset the setting that may have been set
// during failed configuration.
ctx.sql.set_raw_config("is_chatmail", Some("0")).await?;
}
}
emit_progress(ctx, 900);
// Drop the imap connection explicitly
// to make sure that it's not forgotten in a future refactoring
@@ -507,7 +556,7 @@ pub(crate) async fn configure(
drop(imap);
}
progress!(ctx, 910);
emit_progress(ctx, 910);
configured_param
.clone()
@@ -518,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")?;
@@ -566,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/>.
@@ -581,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(
@@ -593,7 +642,7 @@ async fn get_autoconfig(
{
return Some(res);
}
progress!(ctx, 320);
emit_progress(ctx, 320);
if let Ok(res) = outlk_autodiscover(
ctx,
@@ -604,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(
@@ -681,7 +730,8 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_early_configure_failure_is_reported() -> Result<()> {
let t = TestContext::new().await;
let mut param = login_param_from_host("example.org");
let mark_as_autorelay = false;
let mut param = login_param_from_host("example.org", mark_as_autorelay);
// An ongoing process, e.g. a backup import,
// makes configuration fail without ever contacting a relay.
@@ -700,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;
@@ -726,7 +798,7 @@ mod tests {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
// Setting ConfiguredAddr on an unconfigured account creates a pseudo transport
add_pseudo_transport(t, "primary@example.org").await?;
t.set_config(Config::ConfiguredAddr, Some("primary@example.org"))
.await?;
assert_eq!(t.count_transports().await?, 1);
+4 -6
View File
@@ -148,8 +148,9 @@ pub const DC_DESIRED_TEXT_LINE_LEN: usize = 100;
/// `char`s), not Unicode Grapheme Clusters.
pub const DC_DESIRED_TEXT_LEN: usize = DC_DESIRED_TEXT_LINE_LEN * DC_DESIRED_TEXT_LINES;
// max. weight of images to send w/o recoding
pub const BALANCED_IMAGE_BYTES: usize = 500_000;
/// max. weight of images to send w/o recoding.
// this is an estimation to the size we get when recoding high detail images.
pub const BALANCED_IMAGE_BYTES: usize = 940_000;
pub const WORSE_IMAGE_BYTES: usize = 130_000;
// max. width/height and bytes of an avatar
@@ -159,7 +160,7 @@ pub(crate) const WORSE_AVATAR_SIZE: u32 = 256;
pub(crate) const WORSE_AVATAR_BYTES: usize = 20_000; // this also fits to Outlook servers don't allowing headers larger than 32k.
// max. width/height of images scaled down because of being too huge
pub const BALANCED_IMAGE_SIZE: u32 = 1280;
pub const BALANCED_IMAGE_SIZE: u32 = 1760;
pub const WORSE_IMAGE_SIZE: u32 = 640;
/// Limit for received images size. Bigger images become `Viewtype::File` to avoid excessive memory
@@ -171,9 +172,6 @@ pub const MAX_RCVD_IMAGE_PIXELS: u32 = 50_000_000;
// Relays typically advertise their limit via IMAP METADATA.
pub(crate) const DEFAULT_MAX_SMTP_RCPT_TO: u32 = 50;
/// How far the last quota check needs to be in the past to be checked by the background function (in seconds).
pub(crate) const DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT: u64 = 12 * 60 * 60; // 12 hours
/// How far in the future the sender timestamp of a message is allowed to be, in seconds. Also used
/// in the group membership consistency algo to reject outdated membership changes.
pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
+50 -141
View File
@@ -38,13 +38,35 @@ use crate::param::{Param, Params};
use crate::pgp::{addresses_from_public_key, merge_openpgp_certificates};
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, ensure_and_debug_assert_ne, stock_str,
};
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
@@ -488,8 +510,8 @@ pub struct Contact {
/// The contact ID.
pub id: ContactId,
/// Contact name. It is recommended to use `Contact::get_name`,
/// `Contact::get_display_name` or `Contact::get_name_n_addr` to access this field.
/// Contact name. It is recommended to use `Contact::get_name`
/// or `Contact::get_display_name` to access this field.
/// May be empty, initially set to `authname`.
name: String,
@@ -562,10 +584,10 @@ pub enum Origin {
/// To: of incoming messages of unknown sender
IncomingUnknownTo = 0x40,
/// Address scanned but not verified.
/// Address scanned from a QR code.
UnhandledQrScan = 0x80,
/// Address scanned from a SecureJoin QR code, but not verified yet.
/// Address scanned from a SecureJoin QR code.
UnhandledSecurejoinQrScan = 0x81,
/// Reply-To: of incoming message of known sender
@@ -596,14 +618,14 @@ pub enum Origin {
/// address is in our address book
AddressBook = 0x80000,
/// set on Alice's side for contacts like Bob that have scanned the QR code offered by her. Only means the contact has once been established using the "securejoin" procedure in the past, getting the current key verification status requires calling contact_is_verified() !
/// Set on Alice's side for contacts like Bob that have scanned the QR code offered by her.
/// Only means the contact has once been established using the "securejoin" procedure.
SecurejoinInvited = 0x0100_0000,
/// Set on Bob's side for contacts scanned from a QR code.
/// Only means the contact has been scanned from the QR code,
/// but does not mean that securejoin succeeded
/// or the key has not changed since the last scan.
/// Getting the current key verification status requires calling contact_is_verified() !
SecurejoinJoined = 0x0200_0000,
/// contact added manually by create_contact(), this should be the largest origin as otherwise the user cannot modify the names
@@ -727,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.
@@ -1578,7 +1613,7 @@ WHERE addr=?
/// May be an empty string.
///
/// This name is typically used in a form where the user can edit the name of a contact.
/// To get a fine name to display in lists etc., use `Contact::get_display_name` or `Contact::get_name_n_addr`.
/// To get a fine name to display in lists etc., use `Contact::get_display_name`.
pub fn get_name(&self) -> &str {
&self.name
}
@@ -1598,26 +1633,6 @@ WHERE addr=?
&self.addr
}
/// Get a summary of name and address.
///
/// The returned string is either "Name (email@domain.com)" or just
/// "email@domain.com" if the name is unset.
///
/// The result should only be used locally and never sent over the network
/// as it leaks the local contact name.
///
/// The summary is typically used when asking the user something about the contact.
/// The attached email address makes the question unique, eg. "Chat with Alan Miller (am@uniquedomain.com)?"
pub fn get_name_n_addr(&self) -> String {
if !self.name.is_empty() {
format!("{} ({})", self.name, self.addr)
} else if !self.authname.is_empty() {
format!("{} ({})", self.authname, self.addr)
} else {
(&self.addr).into()
}
}
/// Get the contact's profile image.
/// This is the image set by each remote user on their own
/// using set_config(context, "selfavatar", image).
@@ -1690,50 +1705,6 @@ WHERE addr=?
Ok(self.public_key(context).await?.is_some())
}
/// Returns true if the contact
/// can be added to verified chats.
///
/// If contact is verified
/// UI should display green checkmark after the contact name
/// in contact list items and
/// in chat member list items.
///
/// Use [Self::get_verifier_id] to display the verifier contact
/// in the info section of the contact profile.
pub async fn is_verified(&self, context: &Context) -> Result<bool> {
// We're always sort of secured-verified as we could verify the key on this device any time with the key
// on this device
if self.id == ContactId::SELF {
return Ok(true);
}
Ok(self.get_verifier_id(context).await?.is_some())
}
/// Returns the `ContactId` that verified the contact.
///
/// If this returns Some(_),
/// display green checkmark in the profile and "Introduced by ..." line
/// with the name of the contact.
///
/// If this returns `Some(None)`, then the contact is verified,
/// but it's unclear by whom.
pub async fn get_verifier_id(&self, context: &Context) -> Result<Option<Option<ContactId>>> {
let verifier_id: u32 = context
.sql
.query_get_value("SELECT verifier FROM contacts WHERE id=?", (self.id,))
.await?
.with_context(|| format!("Contact {} does not exist", self.id))?;
if verifier_id == 0 {
Ok(None)
} else if verifier_id == self.id.to_u32() {
Ok(Some(None))
} else {
Ok(Some(Some(ContactId::new(verifier_id))))
}
}
/// Returns the number of real (i.e. non-special) contacts in the database.
pub async fn get_real_cnt(context: &Context) -> Result<usize> {
if !context.sql.is_open().await {
@@ -1997,68 +1968,6 @@ pub(crate) async fn update_last_seen(
Ok(())
}
/// Marks contact `contact_id` as verified by `verifier_id`.
///
/// `verifier_id == None` means that the verifier is unknown.
pub(crate) async fn mark_contact_id_as_verified(
context: &Context,
contact_id: ContactId,
verifier_id: Option<ContactId>,
) -> Result<()> {
ensure_and_debug_assert_ne!(contact_id, ContactId::SELF,);
ensure_and_debug_assert_ne!(
Some(contact_id),
verifier_id,
"Contact cannot be verified by self",
);
let by_self = verifier_id == Some(ContactId::SELF);
let mut verifier_id = verifier_id.unwrap_or(contact_id);
context
.sql
.transaction(|transaction| {
let contact_fingerprint: String = transaction.query_row(
"SELECT fingerprint FROM contacts WHERE id=?",
(contact_id,),
|row| row.get(0),
)?;
if contact_fingerprint.is_empty() {
bail!("Non-key-contact {contact_id} cannot be verified");
}
if verifier_id != ContactId::SELF {
let (verifier_fingerprint, verifier_verifier_id): (String, ContactId) = transaction
.query_row(
"SELECT fingerprint, verifier FROM contacts WHERE id=?",
(verifier_id,),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if verifier_fingerprint.is_empty() {
bail!(
"Contact {contact_id} cannot be verified by non-key-contact {verifier_id}"
);
}
ensure!(
verifier_id == contact_id || verifier_verifier_id != ContactId::UNDEFINED,
"Contact {contact_id} cannot be verified by unverified contact {verifier_id}",
);
if verifier_verifier_id == verifier_id {
// Avoid introducing incorrect reverse chains: if the verifier itself has an
// unknown verifier, it may be `contact_id` actually (directly or indirectly) on
// the other device (which is needed for getting "verified by unknown contact"
// in the first place).
verifier_id = contact_id;
}
}
transaction.execute(
"UPDATE contacts SET verifier=?1
WHERE id=?2 AND (verifier=0 OR verifier=id OR ?3)",
(verifier_id, contact_id, by_self),
)?;
Ok(())
})
.await?;
Ok(())
}
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
}
+53 -43
View File
@@ -253,7 +253,6 @@ async fn test_add_or_lookup() {
assert_eq!(contact.get_authname(), "bla foo");
assert_eq!(contact.get_display_name(), "Name one");
assert_eq!(contact.get_addr(), "one@eins.org");
assert_eq!(contact.get_name_n_addr(), "Name one (one@eins.org)");
// modify first added contact
let (contact_id_test, sth_modified) = Contact::add_or_lookup(
@@ -286,7 +285,6 @@ async fn test_add_or_lookup() {
assert_eq!(contact.get_name(), "");
assert_eq!(contact.get_display_name(), "three@drei.sam");
assert_eq!(contact.get_addr(), "three@drei.sam");
assert_eq!(contact.get_name_n_addr(), "three@drei.sam");
// add name to third contact from incoming message (this becomes authorized name)
let (contact_id_test, sth_modified) = Contact::add_or_lookup(
@@ -300,7 +298,6 @@ async fn test_add_or_lookup() {
assert_eq!(contact_id, contact_id_test);
assert_eq!(sth_modified, Modifier::Modified);
let contact = Contact::get_by_id(&t, contact_id).await.unwrap();
assert_eq!(contact.get_name_n_addr(), "m. serious (three@drei.sam)");
assert!(!contact.is_blocked());
// manually edit name of third contact (does not changed authorized name)
@@ -316,7 +313,6 @@ async fn test_add_or_lookup() {
assert_eq!(sth_modified, Modifier::Modified);
let contact = Contact::get_by_id(&t, contact_id).await.unwrap();
assert_eq!(contact.get_authname(), "m. serious");
assert_eq!(contact.get_name_n_addr(), "schnucki (three@drei.sam)");
assert!(!contact.is_blocked());
// Fourth contact:
@@ -334,7 +330,6 @@ async fn test_add_or_lookup() {
assert_eq!(contact.get_name(), "Wonderland, Alice");
assert_eq!(contact.get_display_name(), "Wonderland, Alice");
assert_eq!(contact.get_addr(), "alice@w.de");
assert_eq!(contact.get_name_n_addr(), "Wonderland, Alice (alice@w.de)");
// check SELF
let contact = Contact::get_by_id(&t, ContactId::SELF).await.unwrap();
@@ -373,7 +368,6 @@ async fn test_contact_name_changes() -> Result<()> {
assert_eq!(contact.get_authname(), "");
assert_eq!(contact.get_name(), "");
assert_eq!(contact.get_display_name(), "f@example.org");
assert_eq!(contact.get_name_n_addr(), "f@example.org");
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
assert_eq!(contacts.len(), 0);
@@ -399,7 +393,6 @@ async fn test_contact_name_changes() -> Result<()> {
assert_eq!(contact.get_authname(), "Flobbyfoo");
assert_eq!(contact.get_name(), "");
assert_eq!(contact.get_display_name(), "Flobbyfoo");
assert_eq!(contact.get_name_n_addr(), "Flobbyfoo (f@example.org)");
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
assert_eq!(contacts.len(), 0);
let contacts = Contact::get_all(&t, 0, Some("flobbyfoo")).await?;
@@ -429,7 +422,6 @@ async fn test_contact_name_changes() -> Result<()> {
assert_eq!(contact.get_authname(), "Foo Flobby");
assert_eq!(contact.get_name(), "");
assert_eq!(contact.get_display_name(), "Foo Flobby");
assert_eq!(contact.get_name_n_addr(), "Foo Flobby (f@example.org)");
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
assert_eq!(contacts.len(), 0);
let contacts = Contact::get_all(&t, 0, Some("flobbyfoo")).await?;
@@ -447,7 +439,6 @@ async fn test_contact_name_changes() -> Result<()> {
assert_eq!(contact.get_authname(), "Foo Flobby");
assert_eq!(contact.get_name(), "Falk");
assert_eq!(contact.get_display_name(), "Falk");
assert_eq!(contact.get_name_n_addr(), "Falk (f@example.org)");
let contacts = Contact::get_all(&t, 0, Some("f@example.org")).await?;
assert_eq!(contacts.len(), 0);
let contacts = Contact::get_all(&t, 0, Some("falk")).await?;
@@ -1057,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();
@@ -1070,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(())
}
@@ -1096,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;
@@ -1108,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;
@@ -1171,27 +1204,6 @@ async fn test_lookup_id_by_addr_recent_accepted() -> Result<()> {
test_lookup_id_by_addr_recent_ext(accept_unencrypted_chat).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_verified_by_none() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let contact_id = Contact::create(&alice, "Bob", "bob@example.net").await?;
let contact = Contact::get_by_id(&alice, contact_id).await?;
assert!(contact.get_verifier_id(&alice).await?.is_none());
// Receive a message from Bob to save the public key.
let chat = bob.create_chat(&alice).await;
let sent_msg = bob.send_text(chat.id, "moin").await;
alice.recv_msg(&sent_msg).await;
let contact = Contact::get_by_id(&alice, contact_id).await?;
assert!(contact.get_verifier_id(&alice).await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync_create() -> Result<()> {
let alice0 = &TestContext::new_alice().await;
@@ -1240,7 +1252,7 @@ async fn test_make_n_import_vcard() -> Result<()> {
tokio::fs::write(&avatar_path, avatar_bytes).await?;
bob.set_config(Config::Selfavatar, Some(avatar_path.to_str().unwrap()))
.await?;
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
let bob_addr = bob.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_biography = bob.get_config(Config::Selfstatus).await?.unwrap();
let chat = bob.create_chat(alice).await;
let sent_msg = bob.send_text(chat.id, "moin").await;
@@ -1336,7 +1348,7 @@ async fn test_make_n_import_vcard() -> Result<()> {
async fn test_import_vcard_key_change() -> Result<()> {
let alice = &TestContext::new_alice().await;
let bob = &TestContext::new_bob().await;
let bob_addr = &bob.get_config(Config::Addr).await?.unwrap();
let bob_addr = &bob.get_config(Config::ConfiguredAddr).await?.unwrap();
bob.set_config(Config::Displayname, Some("Bob")).await?;
let vcard = make_vcard(bob, &[ContactId::SELF]).await?;
alice.evtracker.clear_events();
@@ -1391,13 +1403,11 @@ async fn test_import_vcard_key_change() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_self_is_verified() -> Result<()> {
async fn test_self_is_key_contact() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let contact = Contact::get_by_id(&alice, ContactId::SELF).await?;
assert_eq!(contact.is_verified(&alice).await?, true);
assert!(contact.get_verifier_id(&alice).await?.is_none());
assert!(contact.is_key_contact());
Ok(())
+26 -114
View File
@@ -16,11 +16,11 @@ use tokio::sync::{Mutex, Notify, RwLock};
use crate::chat::{ChatId, get_chat_cnt};
use crate::config::Config;
use crate::constants::{self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_VERSION_STR};
use crate::constants::{self, DC_VERSION_STR};
use crate::contact::{Contact, ContactId};
use crate::debug_logging::DebugLogging;
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::imap::{Imap, ServerMetadata};
use crate::imap::ServerMetadata;
use crate::log::warn;
use crate::logged_debug_assert;
use crate::message::{self, MessageState, MsgId};
@@ -67,7 +67,6 @@ pub struct ContextBuilder {
id: u32,
events: Events,
stock_strings: StockStrings,
password: Option<String>,
push_subscriber: Option<PushSubscriber>,
}
@@ -84,7 +83,6 @@ impl ContextBuilder {
id: rand::random(),
events: Events::new(),
stock_strings: StockStrings::new(),
password: None,
push_subscriber: None,
}
}
@@ -131,19 +129,6 @@ impl ContextBuilder {
self
}
/// Sets the password to unlock the database.
/// Deprecated 2025-11:
/// - Db encryption does nothing with blobs, so fs/disk encryption is recommended.
/// - Isolation from other apps is needed anyway.
///
/// If an encrypted database is used it must be opened with a password. Setting a
/// password on a new database will enable encryption.
#[deprecated(since = "TBD")]
pub fn with_password(mut self, password: String) -> Self {
self.password = Some(password);
self
}
/// Sets push subscriber.
pub(crate) fn with_push_subscriber(mut self, push_subscriber: PushSubscriber) -> Self {
self.push_subscriber = Some(push_subscriber);
@@ -168,11 +153,10 @@ impl ContextBuilder {
///
/// Returns error if context cannot be opened.
pub async fn open(self) -> Result<Context> {
let password = self.password.clone().unwrap_or_default();
let context = self.build().await?;
match context.open(password).await? {
match context.open().await? {
true => Ok(context),
false => bail!("database could not be decrypted, incorrect or missing password"),
false => bail!("FIXME database could not be decrypted, incorrect or missing password"),
}
}
}
@@ -386,10 +370,7 @@ impl Context {
let context =
Self::new_closed(dbfile, id, events, stock_strings, Default::default()).await?;
// Open the database if is not encrypted.
if context.check_passphrase("".to_string()).await? {
context.sql.open(&context, "".to_string()).await?;
}
context.sql.open(&context).await?;
Ok(context)
}
@@ -433,20 +414,9 @@ impl Context {
/// Returns true if passphrase is correct, false is passphrase is not correct. Fails on other
/// errors.
#[deprecated(since = "TBD")]
pub async fn open(&self, passphrase: String) -> Result<bool> {
if self.sql.check_passphrase(passphrase.clone()).await? {
self.sql.open(self, passphrase).await?;
Ok(true)
} else {
Ok(false)
}
}
/// Changes encrypted database passphrase.
/// Deprecated 2025-11, see [`ContextBuilder::with_password()`] for reasoning.
pub async fn change_passphrase(&self, passphrase: String) -> Result<()> {
self.sql.change_passphrase(passphrase).await?;
Ok(())
pub async fn open(&self) -> Result<bool> {
self.sql.open(self).await?;
Ok(true)
}
/// Returns true if database is open.
@@ -454,15 +424,6 @@ impl Context {
self.sql.is_open().await
}
/// Tests the database passphrase.
///
/// Returns true if passphrase is correct.
///
/// Fails if database is already open.
pub(crate) async fn check_passphrase(&self, passphrase: String) -> Result<bool> {
self.sql.check_passphrase(passphrase).await
}
pub(crate) fn with_blobdir(
dbfile: PathBuf,
blobdir: PathBuf,
@@ -568,20 +529,9 @@ impl Context {
self.scheduler.maybe_network().await;
}
/// Deprecated, we are trying to get rid of this global setting.
/// It is possible to configure a profile with both chatmail relays
/// and classical email servers.
///
/// Returns true if an account is on a chatmail server.
pub async fn is_chatmail(&self) -> Result<bool> {
self.get_config_bool(Config::IsChatmail).await
}
/// Returns maximum number of recipients a single email can be sent to.
pub(crate) async fn get_max_smtp_rcpt_to(&self) -> Result<u32> {
let Some((transport_id, param)) = ConfiguredLoginParam::load(self).await? else {
bail!("Not configured");
};
/// Returns maximum number of recipients a single email can be sent to
/// over the transport `transport_id`, which sends from `addr`.
pub(crate) async fn get_max_smtp_rcpt_to(&self, transport_id: u32, addr: &str) -> Result<u32> {
let metadata_limit = self
.metadata
.read()
@@ -591,64 +541,40 @@ impl Context {
if let Some(limit) = metadata_limit {
return Ok(limit);
}
if let Some(limit) =
crate::provider::legacy_settings_for_addr(&param.addr)?.max_smtp_rcpt_to
{
if let Some(limit) = crate::provider::legacy_settings_for_addr(addr)?.max_smtp_rcpt_to {
return Ok(limit);
}
Ok(constants::DEFAULT_MAX_SMTP_RCPT_TO)
}
/// Does a single round of fetching from IMAP and returns.
/// Does a single round of fetching messages from all transports and returns.
///
/// Can be used even if I/O is currently stopped.
/// If I/O is currently stopped, starts a new IMAP connection
/// and fetches from Inbox and DeltaChat folders.
/// If IO is stopped, pauses the scheduler and fetches over a dedicated connection
/// per transport, returning as soon as one of them fetched messages.
/// If IO is running, interrupts IMAP IDLE on all transports
/// and waits until they are done fetching.
///
/// Does not wait for outgoing messages to be sent out,
/// use [`crate::accounts::Accounts::is_sending_finished`] for that.
pub async fn background_fetch(&self) -> Result<()> {
if !(self.is_configured().await?) {
return Ok(());
}
let address = self.get_primary_self_addr().await?;
let time_start = tools::Time::now();
info!(self, "background_fetch started fetching {address}.");
info!(self, "background_fetch started.");
if self.scheduler.is_running().await {
self.scheduler.maybe_network().await;
self.wait_for_all_work_done().await;
self.scheduler.interrupt_inbox_idle().await;
let include_smtp = false;
self.wait_for_work_done(include_smtp).await;
} else {
// Pause the scheduler to ensure another connection does not start
// while we are fetching on a dedicated connection.
let _pause_guard = self.scheduler.pause(self).await?;
// Start a new dedicated connection.
let mut connection = Imap::new_configured(self, channel::bounded(1).1).await?;
let mut session = connection.prepare(self).await?;
// Fetch IMAP folders.
let folder = connection.folder.clone();
connection
.fetch_move_delete(self, &mut session, &folder)
.await?;
// Update quota (to send warning if full) - but only check it once in a while.
// note: For now this only checks quota of primary transport,
// because background check only checks primary transport at the moment
if self
.quota_needs_update(
session.transport_id(),
DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT,
)
.await
&& let Err(err) = self.update_recent_quota(&mut session, &folder).await
{
warn!(self, "Failed to update quota: {err:#}.");
}
self.scheduler.background_fetch_any(self).await?;
}
info!(
self,
"background_fetch done for {address} took {:?}.",
"background_fetch done, took {:?}.",
time_elapsed(&time_start),
);
@@ -885,13 +811,6 @@ impl Context {
res.insert("number_of_contacts", contacts.to_string());
res.insert("database_dir", self.get_dbfile().display().to_string());
res.insert("database_version", dbversion.to_string());
res.insert(
"database_encrypted",
self.sql
.is_encrypted()
.await
.map_or_else(|| "closed".to_string(), |b| b.to_string()),
);
res.insert("journal_mode", journal_mode);
res.insert("blobdir", self.get_blobdir().display().to_string());
res.insert(
@@ -907,13 +826,6 @@ impl Context {
res.insert("imap_server_id", format!("{server_id:?}"));
}
res.insert("is_chatmail", self.is_chatmail().await?.to_string());
res.insert(
"fix_is_chatmail",
self.get_config_bool(Config::FixIsChatmail)
.await?
.to_string(),
);
res.insert(
"is_muted",
self.get_config_bool(Config::IsMuted).await?.to_string(),
+24 -60
View File
@@ -1,16 +1,37 @@
use anyhow::Context as _;
use strum::IntoEnumIterator;
use tempfile::tempdir;
use super::*;
use crate::chat::{Chat, MuteDuration, get_chat_contacts, get_chat_msgs, send_msg, set_muted};
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::constants::{Chattype, DEFAULT_MAX_SMTP_RCPT_TO};
use crate::message::Message;
use crate::receive_imf::receive_imf;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
use crate::tools::{SystemTime, create_outgoing_rfc724_mid};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_max_smtp_rcpt_to() -> Result<()> {
let t = TestContext::new().await;
assert_eq!(
t.get_max_smtp_rcpt_to(2, "alice@example.org").await?,
DEFAULT_MAX_SMTP_RCPT_TO
);
for (transport_id, limit) in [(1, 3), (2, 7)] {
t.metadata.write().await.insert(
transport_id,
ServerMetadata {
max_smtp_rcpt_to: Some(limit),
..Default::default()
},
);
}
assert_eq!(t.get_max_smtp_rcpt_to(2, "alice@example.org").await?, 7);
assert_eq!(t.get_max_smtp_rcpt_to(1, "alice@example.org").await?, 3);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_wrong_db() -> Result<()> {
let tmp = tempfile::tempdir()?;
@@ -463,63 +484,6 @@ async fn test_limit_search_msgs() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_check_passphrase() -> Result<()> {
let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite");
let context = ContextBuilder::new(dbfile.clone())
.with_id(1)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true);
assert_eq!(context.is_open().await, true);
drop(context);
let context = ContextBuilder::new(dbfile)
.with_id(2)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.is_open().await, false);
assert_eq!(context.check_passphrase("bar".to_string()).await?, false);
assert_eq!(context.open("false".to_string()).await?, false);
assert_eq!(context.open("foo".to_string()).await?, true);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_context_change_passphrase() -> Result<()> {
let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite");
let context = ContextBuilder::new(dbfile)
.with_id(1)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true);
assert_eq!(context.is_open().await, true);
context
.set_config(Config::Addr, Some("alice@example.org"))
.await?;
context
.change_passphrase("bar".to_string())
.await
.context("Failed to change passphrase")?;
assert_eq!(
context.get_config(Config::Addr).await?.unwrap(),
"alice@example.org"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ongoing() -> Result<()> {
let context = TestContext::new().await;
+1 -6
View File
@@ -6,7 +6,6 @@ use anyhow::{Result, anyhow, bail, ensure};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
use crate::imap::session::Session;
use crate::log::warn;
@@ -23,9 +22,6 @@ pub(crate) use post_msg_metadata::PostMsgMetadata;
/// KiB).
pub(crate) const PRE_MSG_ATTACHMENT_SIZE_THRESHOLD: u64 = 140_000;
/// Max size for pre messages. A warning is emitted when this is exceeded.
pub(crate) const PRE_MSG_SIZE_WARNING_THRESHOLD: usize = 150_000;
/// Download state of the message.
#[derive(
Debug,
@@ -167,8 +163,7 @@ pub(crate) async fn download_msg(
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
.await?;
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
if ephemeral::should_delete_all_downloaded_messages(bcc_self, session.is_chatmail()) {
if ephemeral::should_delete_all_downloaded_messages(context).await? {
// Now that the message was downloaded, it likely needs to be deleted;
// trigger a re-check by interrupting the inbox folder.
// This is mainly needed to make the tests pass;
-2
View File
@@ -6,7 +6,6 @@ mod tests {
use crate::chat;
use crate::chat::send_text_msg;
use crate::config::Config;
use crate::message::Message;
use crate::mimeparser::SystemMessage;
use crate::receive_imf::receive_imf;
@@ -61,7 +60,6 @@ Sent with my Delta Chat Messenger: https://delta.chat";
async fn test_chatmail_can_send_unencrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
bob.set_config_bool(Config::IsChatmail, true).await?;
bob.allow_unencrypted().await?;
let bob_chat_id = receive_imf(
bob,
+28 -14
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 });
}
@@ -654,12 +668,11 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
pub(crate) async fn delete_expired_imap_messages(
context: &Context,
transport_id: u32,
is_chatmail: bool,
) -> Result<()> {
let now = time();
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
if should_delete_all_downloaded_messages(bcc_self, is_chatmail) {
if should_delete_all_downloaded_messages(context).await? {
// This is the only device using this relay.
// Mark all downloaded messages for deletion, because they are not needed anymore.
//
@@ -708,7 +721,7 @@ pub(crate) async fn delete_expired_imap_messages(
)
.await?;
} else {
// Single device.
// Single device, but unencrypted messages are allowed.
// Delete all expired and encrypted messages.
context
.sql
@@ -736,8 +749,9 @@ pub(crate) async fn delete_expired_imap_messages(
Ok(())
}
pub(crate) fn should_delete_all_downloaded_messages(bcc_self: bool, is_chatmail: bool) -> bool {
!bcc_self && is_chatmail
pub(crate) async fn should_delete_all_downloaded_messages(context: &Context) -> Result<bool> {
Ok(!context.get_config_bool(Config::BccSelf).await?
&& context.get_config_bool(Config::ForceEncryption).await?)
}
/// Start ephemeral timers for seen messages if they are not started
+13 -7
View File
@@ -509,7 +509,7 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
.await?;
}
for (is_chatmail, other_transport, bcc_self) in [
for (force_encryption, other_transport, bcc_self) in [
(false, false, false),
(false, false, true),
(false, true, false),
@@ -520,10 +520,12 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
(true, true, true),
] {
println!(
"Testing combination is_chatmail={is_chatmail}, other_transport={other_transport}, bcc_self={bcc_self}"
"Testing combination force_encryption={force_encryption}, other_transport={other_transport}, bcc_self={bcc_self}"
);
t.set_config_bool(Config::BccSelf, bcc_self).await?;
t.set_config_bool(Config::ForceEncryption, force_encryption)
.await?;
delete_expired_imap_messages(
&t,
@@ -532,7 +534,6 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
} else {
transport_id
},
is_chatmail,
)
.await?;
@@ -548,7 +549,7 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, !bcc_self);
assert_eq!(
is_deleted(&t, "no_expire_unencrypted@localhost").await?,
is_chatmail && !bcc_self
force_encryption && !bcc_self
);
assert_eq!(is_deleted(&t, "future@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "expired_post@localhost").await?, true);
@@ -563,9 +564,10 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
reset_targets(&t).await;
}
// With BccSelf=true, non-expired messages are kept even if `is_chatmail` is true
// With BccSelf=true, non-expired messages are kept even if `force_encryption` is true
t.set_config_bool(Config::BccSelf, true).await?;
delete_expired_imap_messages(&t, transport_id, true).await?;
t.set_config_bool(Config::ForceEncryption, true).await?;
delete_expired_imap_messages(&t, transport_id).await?;
assert_eq!(is_deleted(&t, "expired@localhost").await?, true);
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, false);
assert_eq!(is_deleted(&t, "done_pre@localhost").await?, false);
@@ -676,12 +678,16 @@ async fn test_ephemeral_msg_offline() -> Result<()> {
.await?;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(alice, chat.id, &mut msg).await.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
let stmt = "SELECT COUNT(*) FROM smtp2 WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
let now = time();
check_msg_will_be_deleted(alice, msg.id, &chat, now, now + i64::from(duration) + 1).await?;
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
alice
.assert_warn("No SMTP connection candidates provided")
.await;
Ok(())
}
+21 -11
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.
///
@@ -214,7 +219,6 @@ pub enum EventType {
},
/// Chat changed. The name or the image of a chat group was changed or members were added or removed.
/// Or the verify state of a chat has changed.
/// See dc_set_chat_name(), dc_set_chat_profile_image(), dc_add_contact_to_chat()
/// and dc_remove_contact_from_chat().
///
@@ -302,8 +306,7 @@ pub enum EventType {
contact_id: ContactId,
/// Progress as:
/// 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself."
/// (Bob has verified alice and waits until Alice does the same for him)
/// 400=vg-/vc-request-with-auth sent, typically shown as "introducing myself."
/// 1000=vg-member-added/vc-contact-confirm received
progress: u16,
},
@@ -357,11 +360,18 @@ pub enum EventType {
msg_id: MsgId,
},
/// Tells that the Background fetch was completed (or timed out).
/// This event acts as a marker, when you reach this event you can be sure
/// that all events emitted during the background fetch were processed.
/// Tells that a background fetch call is done:
/// the fetch completed, timed out, was stopped or was not started.
///
/// This event is only emitted by the account manager
/// For the call that started the fetch, this event acts as a marker:
/// all events emitted during the fetch were processed once it is reached.
/// A call made while another background fetch is running gets the event immediately,
/// and the running fetch keeps emitting events until its own marker.
///
/// The event carries no data identifying the call it belongs to,
/// so it is unambiguous only if there are no concurrent background fetch calls.
///
/// This event is only emitted by the account manager.
AccountsBackgroundFetchDone,
/// Inform that set of chats or the order of the chats in the chatlist has changed.
///
@@ -429,7 +439,7 @@ pub enum EventType {
chat_id: ChatId,
},
/// One or more transports has changed or another transport is primary now.
/// One or more transports has changed or another transport is used for sending now.
///
/// UI should update the list.
///
-1
View File
@@ -63,7 +63,6 @@ pub enum HeaderDef {
ChatGroupDescription,
ChatGroupDescriptionChanged,
ChatGroupDescriptionTimestamp,
ChatVerified,
ChatGroupAvatar,
ChatUserAvatar,
ChatVoiceMessage,
+51 -118
View File
@@ -19,6 +19,7 @@ use async_imap::types::{Fetch, Flag, UnsolicitedResponse};
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use ratelimit::Ratelimit;
use tokio_util::sync::CancellationToken;
use url::Url;
use crate::chat::{self, add_device_msg};
@@ -104,6 +105,10 @@ pub(crate) struct Imap {
/// IMAP UID resync request receiver.
pub(crate) resync_request_receiver: async_channel::Receiver<()>,
/// The background fetch is cancelled once messages are fetched from one of the transports,
/// so that the other transports fetch nothing.
pub(crate) background_fetch_stop_token: Option<CancellationToken>,
}
#[derive(Debug, Default)]
@@ -124,6 +129,8 @@ pub(crate) struct ServerMetadata {
/// True if we think the relay supports push notifications.
/// This gates wether we attempt to write an encrypted device token
/// to per-transport IMAP metadata key `/private/devicetoken`.
///
/// Any `/shared/vendor/deltachat/` entry identifies a chatmail relay.
pub supports_push: bool,
/// ICE servers for WebRTC calls.
@@ -237,21 +244,10 @@ impl Imap {
ratelimit: Ratelimit::new(Duration::new(120, 0), 2.0),
resync_request_sender,
resync_request_receiver,
background_fetch_stop_token: None,
})
}
/// Creates new disconnected IMAP client using configured parameters.
pub async fn new_configured(
context: &Context,
idle_interrupt_receiver: Receiver<()>,
) -> Result<Self> {
let (transport_id, param) = ConfiguredLoginParam::load(context)
.await?
.context("Not configured")?;
let imap = Self::new(context, transport_id, param, idle_interrupt_receiver).await?;
Ok(imap)
}
/// Returns transport ID of the IMAP client.
pub fn transport_id(&self) -> u32 {
self.transport_id
@@ -305,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();
@@ -321,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;
}
};
@@ -400,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.
@@ -424,16 +423,18 @@ 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.
pub async fn fetch_move_delete(
/// 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_delete(
&mut self,
context: &Context,
session: &mut Session,
watch_folder: &str,
) -> Result<()> {
) -> Result<bool> {
ensure_and_debug_assert!(!watch_folder.is_empty(), "Watched folder cannot be empty");
if !context.sql.is_open().await {
// probably shutdown
@@ -454,16 +455,16 @@ impl Imap {
// Mark expired messages for deletion. Note that `delete_expired_imap_messages` is
// not well optimized and should not be called before fetching.
delete_expired_imap_messages(context, session.transport_id(), session.is_chatmail())
delete_expired_imap_messages(context, session.transport_id())
.await
.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(())
Ok(msgs_fetched)
}
/// Fetches new messages.
@@ -532,6 +533,19 @@ impl Imap {
.context("prefetch")?;
let read_cnt = msgs.len();
let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await;
if let Some(stop_token) = &self.background_fetch_stop_token {
if stop_token.is_cancelled() {
// This also stops the transport that cancelled the token,
// so one background fetch receives at most `uids_to_prefetch` messages.
return Ok((0, false));
}
if read_cnt > 0 {
// Cancel the background fetch on the other transports,
// so that `background_fetch_any()` can return as soon as messages are received
// and the UI can show a notification.
stop_token.cancel();
}
}
let mut uids_fetch: Vec<u32> = Vec::new();
let mut available_post_msgs: Vec<String> = Vec::new();
@@ -559,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?
@@ -606,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")?
@@ -849,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
@@ -939,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:?}",)
})?;
}
}
@@ -1375,6 +1306,8 @@ impl Session {
_ => {}
}
}
let supports_push =
max_smtp_rcpt_to.is_some() || iroh_relay.is_some() || ice_servers.is_some();
let ice_servers = if let Some(ice_servers) = ice_servers {
ice_servers
} else {
@@ -1390,7 +1323,7 @@ impl Session {
admin,
iroh_relay,
max_smtp_rcpt_to,
supports_push: max_smtp_rcpt_to.is_some() || self.capabilities.has_xdeltapush,
supports_push,
ice_servers,
ice_servers_expiration_timestamp,
app_versions,
-15
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,
@@ -25,17 +21,6 @@ pub(crate) struct Capabilities {
/// <https://tools.ietf.org/html/rfc4978>
pub can_compress: bool,
/// True if the server advertises the legacy `XDELTAPUSH` capability.
pub has_xdeltapush: bool,
/// True if the server has an XCHATMAIL capability
/// indicating that it is a <https://github.com/deltachat/chatmail> server.
///
/// This can be used to hide some advanced settings in the UI
/// that are only interesting for normal email accounts,
/// e.g. the ability to move messages to Delta Chat folder.
pub is_chatmail: bool,
/// Server ID if the server supports ID capability.
pub server_id: Option<HashMap<String, String>>,
}
-3
View File
@@ -78,12 +78,9 @@ 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"),
has_xdeltapush: caps.has_str("XDELTAPUSH"),
is_chatmail: caps.has_str("XCHATMAIL"),
server_id,
};
Ok(capabilities)
-9
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
}
@@ -111,11 +107,6 @@ impl Session {
self.capabilities.can_metadata
}
// Returns true if IMAP server has `XCHATMAIL` capability.
pub(crate) fn is_chatmail(&self) -> bool {
self.capabilities.is_chatmail
}
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
/// order of ascending UIDs.
#[expect(clippy::arithmetic_side_effects)]
+17 -27
View File
@@ -200,6 +200,9 @@ async fn import_backup(
backup_to_import: &Path,
passphrase: String,
) -> Result<()> {
if !passphrase.is_empty() {
bail!("Encrypted passphrase is not supported");
}
let backup_file = File::open(backup_to_import).await?;
let file_size = backup_file.metadata().await?.len();
info!(
@@ -210,7 +213,7 @@ async fn import_backup(
context.get_dbfile().display()
);
import_backup_stream(context, backup_file, file_size, passphrase).await?;
import_backup_stream(context, backup_file, file_size).await?;
Ok(())
}
@@ -231,7 +234,6 @@ pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
context: &Context,
backup_file: R,
file_size: u64,
passphrase: String,
) -> Result<()> {
ensure!(
!context.is_configured().await?,
@@ -242,7 +244,7 @@ pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
"Cannot import backup, IO is running"
);
import_backup_stream_inner(context, backup_file, file_size, passphrase)
import_backup_stream_inner(context, backup_file, file_size)
.await
.0
}
@@ -315,7 +317,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
context: &Context,
backup_file: R,
file_size: u64,
passphrase: String,
) -> (Result<()>,) {
let backup_file = ProgressReader::new(backup_file, context.clone(), file_size);
let mut archive = Archive::new(backup_file);
@@ -362,7 +363,7 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
if res.is_ok() {
res = context
.sql
.import(&unpacked_database, passphrase.clone())
.import(&unpacked_database)
.await
.context("cannot import unpacked database");
}
@@ -390,7 +391,7 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
}
context
.sql
.open(context, "".to_string())
.open(context)
.await
.log_err(context)
.ok();
@@ -735,7 +736,7 @@ where
/// overwritten.
///
/// This also verifies that IO is not running during the export.
async fn export_database(context: &Context, dest: &Path, passphrase: String) -> Result<()> {
async fn export_database(context: &Context, dest: &Path, _passphrase: String) -> Result<()> {
ensure!(
!context.scheduler.is_running().await,
"cannot export backup, IO is running"
@@ -745,6 +746,7 @@ async fn export_database(context: &Context, dest: &Path, passphrase: String) ->
let dest = dest
.to_str()
.with_context(|| format!("path {} is not valid unicode", dest.display()))?;
let mut dest_conn = rusqlite::Connection::open(dest)?;
context.set_config(Config::BccSelf, Some("1")).await?;
context
@@ -755,22 +757,12 @@ async fn export_database(context: &Context, dest: &Path, passphrase: String) ->
context
.sql
.call_write(|conn| {
conn.execute("VACUUM;", ())
.map_err(|err| warn!(context, "Vacuum failed, exporting anyway {err}"))
.ok();
conn.execute("ATTACH DATABASE ? AS backup KEY ?", (dest, passphrase))
.context("failed to attach backup database")?;
let res = conn
.query_row("SELECT sqlcipher_export('backup')", [], |_row| Ok(()))
.context("failed to export to attached backup database");
conn.execute(
"UPDATE backup.config SET value='0' WHERE keyname='verified_one_on_one_chats';",
[],
)
.ok(); // Deprecated 2025-07. If verified_one_on_one_chats was not set, this errors, which we ignore
conn.execute("DETACH DATABASE backup", [])
.context("failed to detach backup database")?;
res?;
if let Err(err) = conn.execute("VACUUM", ()) {
warn!(context, "Vacuum failed, exporting anyway: {err:#}.");
}
let backup = rusqlite::backup::Backup::new(conn, &mut dest_conn)?;
backup.run_to_completion(5, std::time::Duration::ZERO, None)?;
Ok(())
})
.await
@@ -961,7 +953,7 @@ mod tests {
assert!(context2.is_configured().await?);
assert_eq!(
context2.get_config(Config::Addr).await?,
context2.get_config(Config::ConfiguredAddr).await?,
Some("alice@example.org".to_string())
);
Ok(())
@@ -981,7 +973,6 @@ mod tests {
context1.get_config(Config::BccSelf).await?,
Some("0".to_string())
);
context1.set_config_bool(Config::IsChatmail, true).await?;
assert_eq!(context1.get_config_bool(Config::IsMuted).await?, false);
context1.set_config_bool(Config::IsMuted, true).await?;
@@ -1001,7 +992,6 @@ mod tests {
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
assert!(context2.is_configured().await?);
assert!(context2.is_chatmail().await?);
for ctx in [context1, context2] {
// BccSelf should be enabled automatically when exporting a backup
assert_eq!(ctx.get_config_bool(Config::BccSelf).await?, true);
@@ -1042,7 +1032,7 @@ mod tests {
ar.unpack(&unpack_dir).await?;
let sql = sql::Sql::new(unpack_dir.path().join(DBFILE_BACKUP_NAME));
sql.open(&context2, "".to_string()).await?;
sql.open(&context2).await?;
assert_eq!(
sql.get_raw_config_int("backup_version").await?.unwrap(),
DCBACKUP_VERSION
+1 -2
View File
@@ -324,7 +324,6 @@ pub async fn get_backup2(
info!(context, "Sending backup authentication token.");
send_stream.write_all(auth_token.as_bytes()).await?;
let passphrase = String::new();
info!(context, "Starting to read backup from the stream.");
let mut file_size_buf = [0u8; 8];
@@ -334,7 +333,7 @@ pub async fn get_backup2(
// Emit a nonzero progress so that UIs can display smth like "Transferring...".
context.emit_event(EventType::ImexProgress(1));
import_backup_stream(context, recv_stream, file_size, passphrase)
import_backup_stream(context, recv_stream, file_size)
.await
.context("Failed to import backup from QUIC stream")?;
info!(context, "Finished importing backup from the stream.");
+4
View File
@@ -662,6 +662,7 @@ mod tests {
use crate::config::Config;
use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
use crate::tools::SystemTime;
use crate::transport::add_pseudo_transport;
static KEYPAIR: LazyLock<SignedSecretKey> = LazyLock::new(alice_keypair);
@@ -811,6 +812,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_self_generate_public() {
let t = TestContext::new().await;
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.unwrap();
@@ -821,6 +823,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_self_generate_secret() {
let t = TestContext::new().await;
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.unwrap();
@@ -833,6 +836,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
use std::thread;
let t = TestContext::new().await;
add_pseudo_transport(&t, "alice@example.org").await.unwrap();
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.unwrap();
+7 -7
View File
@@ -40,7 +40,7 @@ use crate::contact::ContactId;
use crate::context::Context;
use crate::key::{DcKey, SignedPublicKey};
use crate::log::warn;
use crate::mimefactory::render_keyupdate_message;
use crate::mimefactory::keyupdate_message;
use crate::pgp::{pubkey_can_encrypt, relay_addrs};
use crate::smtp::insert_into_smtp;
use crate::tools::{create_outgoing_rfc724_mid, time};
@@ -52,7 +52,7 @@ const KEYUPDATE_CHUNK_CONTACTS: usize = 200;
const KEYUPDATE_MAX_SILENCE: i64 = 3 * 365 * 24 * 3600;
/// Upper bound on the contacts informed after a relay list change, keeping the freshest.
const KEYUPDATE_MAX_RECIPIENTS: usize = 5000;
const KEYUPDATE_MAX_RECIPIENTS: u32 = 5000;
/// A contact to inform: the relays to reach them at, and the key to encrypt to.
struct KeyupdateRecipient {
@@ -63,7 +63,7 @@ struct KeyupdateRecipient {
/// Returns at most `max_recipients` key-contacts to inform.
async fn keyupdate_recipients(
context: &Context,
max_recipients: usize,
max_recipients: u32,
) -> Result<Vec<KeyupdateRecipient>> {
// Single chat contacts only become keyupdate recipient candidates
// if we have a record of a sent message or `last_seen` is not 0.
@@ -145,14 +145,14 @@ async fn keyupdate_recipients(
/// Returns the deduplicated relay addresses to put into the SMTP envelope
/// for the keyupdate encrypted to a `chunk` of recipients.
fn envelope_recipients(chunk: &[KeyupdateRecipient]) -> String {
fn envelope_recipients(chunk: &[KeyupdateRecipient]) -> Vec<String> {
let mut addrs = BTreeSet::new();
for recipient in chunk {
for relay in &recipient.relays {
addrs.insert(addr_normalize(relay));
}
}
Vec::from_iter(addrs).join(" ")
Vec::from_iter(addrs)
}
/// Returns the relay list in the format stored in [`Config::KeyupdateBaseline`].
@@ -192,8 +192,8 @@ pub(crate) async fn maybe_send_keyupdate_message(context: &Context) -> Result<()
let envelope = envelope_recipients(chunk);
let rfc724_mid = create_outgoing_rfc724_mid();
let keys = chunk.iter().map(|r| r.public_key.clone()).collect();
let rendered_message = render_keyupdate_message(context, &rfc724_mid, keys).await?;
insert_into_smtp(context, &rfc724_mid, &envelope, rendered_message).await?;
let queued_msg = keyupdate_message(context, &rfc724_mid, keys, envelope).await?;
insert_into_smtp(context, &rfc724_mid, &queued_msg).await?;
}
// Record only after queueing, so failed queueing is retried by a later check.
+1 -1
View File
@@ -97,7 +97,7 @@ pub mod stock_str;
pub mod storage_usage;
mod sync;
mod token;
mod transport;
pub mod transport;
mod update_helper;
pub mod webxdc;
#[macro_use]
-16
View File
@@ -601,22 +601,6 @@ pub fn get_message_kml(timestamp: i64, latitude: f64, longitude: f64) -> String
)
}
/// Sets the timestamp of the last time location was sent in the chat.
pub async fn set_kml_sent_timestamp(
context: &Context,
chat_id: ChatId,
timestamp: i64,
) -> Result<()> {
context
.sql
.execute(
"UPDATE chats SET locations_last_sent=? WHERE id=?;",
(timestamp, chat_id),
)
.await?;
Ok(())
}
/// Sets the location of the message.
pub async fn set_msg_location_id(context: &Context, msg_id: MsgId, location_id: u32) -> Result<()> {
context
-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(())
}
}
+25 -14
View File
@@ -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| {
@@ -1697,7 +1702,7 @@ pub async fn delete_msgs_ext(
if !msg.pre_rfc724_mid.is_empty() {
stmt.execute((&msg.pre_rfc724_mid,))?;
}
trans.execute("DELETE FROM smtp WHERE msg_id=?", (msg_id,))?;
trans.execute("DELETE FROM smtp2 WHERE msg_id=?", (msg_id,))?;
trans.execute(
"DELETE FROM download WHERE rfc724_mid=?",
(&msg.rfc724_mid,),
@@ -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
+47 -1
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();
@@ -626,11 +648,15 @@ async fn test_delete_msgs_offline() -> Result<()> {
let chat_id = alice.create_chat_id(bob).await;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(alice, chat_id, &mut msg).await.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
let stmt = "SELECT COUNT(*) FROM smtp2 WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
delete_msgs(alice, &[msg.id]).await?;
assert!(!alice.sql.exists(stmt, (msg.id,)).await?);
alice
.assert_warn("No SMTP connection candidates provided")
.await;
Ok(())
}
@@ -756,3 +782,23 @@ async fn test_get_existing_msg_ids() -> Result<()> {
Ok(())
}
#[test]
fn test_can_fail() -> Result<()> {
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());
Ok(())
}
+110 -193
View File
@@ -36,6 +36,9 @@ use crate::param::Param;
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
use crate::pgp::{SeipdVersion, addresses_from_public_key, pubkey_supports_seipdv2, relay_addrs};
use crate::simplify::escape_message_footer_marks;
use crate::smtp::queue::{
Encryption as QueuedEncryption, QueuedMail, SideEffects as QueueSideEffects, ToBeQueuedMail,
};
use crate::stock_str;
use crate::tools::{IsNoneOrEmpty, create_outgoing_rfc724_mid, remove_subject_prefix, time};
use crate::webxdc::StatusUpdateSerial;
@@ -96,6 +99,32 @@ enum Encryption {
Symmetric { shared_secret: String },
}
impl Encryption {
pub fn is_encrypted(&self) -> bool {
match self {
Self::No => false,
Self::Asymmetric { .. } => true,
Self::Symmetric { .. } => true,
}
}
/// Converts into [`QueuedEncryption`] by dropping email addresses corresponding to the keys.
fn into_queued_encryption(self) -> QueuedEncryption {
match self {
Encryption::No => QueuedEncryption::No,
Encryption::Asymmetric { encryption_pubkeys } => QueuedEncryption::Asymmetric {
encryption_pubkeys: encryption_pubkeys
.into_iter()
.map(|(_addr, key)| key)
.collect(),
},
Encryption::Symmetric { shared_secret } => {
QueuedEncryption::Symmetric { shared_secret }
}
}
}
}
/// Helper to construct mime messages.
#[derive(Debug, Clone)]
pub struct MimeFactory {
@@ -195,64 +224,6 @@ pub struct RenderedMessage {
sync_ids_to_delete: Option<String>,
}
/// Email message queued, but not sent yet.
///
/// It is stored unencrypted to
/// make it possible to change protected headers
/// like the From address and Autocrypt header later.
#[derive(Debug, Clone)]
pub(crate) struct QueuedMail {
/// Unencrypted queued message.
///
/// This message has both the headers and the body,
/// but without the From, Autocrypt and Message-ID headers.
///
/// For encrypted messages this is the OpenPGP payload.
raw_message: Vec<u8>,
/// Display name to put in the `From:` field.
///
/// Email address is not determined yet here.
display_name: String,
/// Message-ID.
rfc724_mid: String,
/// Whether the message is encrypted and encryption keys.
encryption: Encryption,
/// If true, Autocrypt header should be added before sending.
should_attach_pubkey: bool,
/// If true, OpenPGP compression may be used.
should_compress: bool,
/// If true, encrypted message should be signed as well.
should_sign: bool,
}
/// Side effects that should be applied at the same time
/// as the message is persisted in the queue.
#[derive(Debug, Clone, Default)]
pub struct RenderSideEffects {
/// Largest timestamp of the location sent in `location.kml` in this message.
pub last_added_location_timestamp: Option<i64>,
/// True if the message has the avatar attached.
///
/// Timestamp of the last time avatar was gossiped should be updated.
pub avatar_is_attached: bool,
/// A comma-separated string of sync-IDs that are used by the rendered email and must be deleted
/// from `multi_device_sync` once the message is actually queued for sending.
pub sync_ids_to_delete: Option<String>,
/// Subject that was rendered into the message.
///
/// Used to update the subject on the sent message object.
pub subject: String,
}
/// Renders [`QueuedMail`].
///
/// Adds headers:
@@ -266,7 +237,6 @@ pub(crate) fn render_queued_mail(
public_key: &SignedPublicKey,
secret_key: &SignedSecretKey,
from_addr: String,
side_effects: RenderSideEffects,
) -> Result<RenderedEmail> {
let QueuedMail {
rfc724_mid,
@@ -276,12 +246,15 @@ pub(crate) fn render_queued_mail(
should_attach_pubkey,
should_compress,
should_sign,
recipients: _,
sent_to: _,
bcc_self: _,
} = queued_mail;
let mut inner_headers: Vec<u8> = Vec::new();
let mut outer_headers: Vec<u8> = Vec::new();
let is_encrypted = !matches!(encryption, Encryption::No);
let is_encrypted = encryption.is_encrypted();
fn add_header(
name: &[u8],
@@ -362,7 +335,6 @@ pub(crate) fn render_queued_mail(
addr: from_addr,
public_key: public_key.clone(),
prefer_encrypt: EncryptPreference::Mutual,
verified: false,
};
let autocrypt_header = mail_builder::headers::raw::Raw::new(aheader.to_string());
add_header(
@@ -419,18 +391,15 @@ pub(crate) fn render_queued_mail(
let sign_key = if should_sign { Some(secret_key) } else { None };
let message = match encryption {
Encryption::No => raw_message,
Encryption::Asymmetric { encryption_pubkeys } => {
QueuedEncryption::No => raw_message,
QueuedEncryption::Asymmetric { encryption_pubkeys } => {
let mut full_raw_message = inner_headers.clone();
full_raw_message.extend(raw_message);
// Asymmetric encryption
// Use SEIPDv2 if all recipients support it.
let seipd_version = if encryption_pubkeys
.iter()
.all(|(_addr, pubkey)| pubkey_supports_seipdv2(pubkey))
{
let seipd_version = if encryption_pubkeys.iter().all(pubkey_supports_seipdv2) {
SeipdVersion::V2
} else {
SeipdVersion::V1
@@ -440,7 +409,7 @@ pub(crate) fn render_queued_mail(
// even for a single-device setup,
// to not reveal if we have a multi-device setup to contacts.
let mut encryption_keyring = vec![public_key.clone()];
encryption_keyring.extend(encryption_pubkeys.iter().map(|(_addr, key)| (*key).clone()));
encryption_keyring.extend(encryption_pubkeys);
let encrypted = crate::pgp::pk_encrypt(
full_raw_message,
@@ -453,7 +422,7 @@ pub(crate) fn render_queued_mail(
let message = wrap_encrypted_part(encrypted);
part_to_bytes(message)
}
Encryption::Symmetric { shared_secret } => {
QueuedEncryption::Symmetric { shared_secret } => {
let mut full_raw_message = inner_headers.clone();
full_raw_message.extend(raw_message);
@@ -473,23 +442,31 @@ pub(crate) fn render_queued_mail(
full_message.extend(message);
Ok(RenderedEmail {
message: String::from_utf8_lossy(&full_message).to_string(),
is_encrypted,
rfc724_mid,
side_effects,
})
}
/// Renders queued mail with the current sending address.
#[cfg(test)]
pub(crate) async fn render_queued_mail_with_context(
queued_mail: QueuedMail,
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 rendered_mail = render_queued_mail(queued_mail, &public_key, &secret_key, from_addr)?;
Ok(rendered_mail)
}
/// Result of rendering a message, ready to be submitted to a send job.
#[derive(Debug, Clone)]
pub struct RenderedEmail {
pub message: String,
pub is_encrypted: bool,
/// Message ID (Message in the sense of Email)
pub rfc724_mid: String,
pub side_effects: RenderSideEffects,
}
fn new_address_with_name(name: &str, address: String) -> Address<'static> {
@@ -848,8 +825,8 @@ impl MimeFactory {
// We don't display avatars for address-contacts, so sending avatars w/o encryption is not
// useful and causes e.g. Outlook to reject a message with a big header, see
// https://support.delta.chat/t/invalid-mime-content-single-text-value-size-32822-exceeded-allowed-maximum-32768-for-the-chat-user-avatar-header/4067.
let attach_selfavatar = Self::should_attach_selfavatar(context, &msg).await
&& !matches!(encryption, Encryption::No);
let attach_selfavatar =
Self::should_attach_selfavatar(context, &msg).await && encryption.is_encrypted();
ensure_and_debug_assert!(
member_timestamps.is_empty()
@@ -1101,7 +1078,10 @@ impl MimeFactory {
};
let self_name = &match self_name {
Some(name) => name,
None => context.get_config(Config::Addr).await?.unwrap_or_default(),
None => context
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default(),
};
stock_str::subject_for_new_contact(context, self_name)
}
@@ -1340,18 +1320,16 @@ impl MimeFactory {
///
/// 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) -> Result<RenderedEmail> {
let from_addr = context.get_primary_self_addr().await?;
pub async fn render(self, context: &Context, from_addr: &str) -> 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)).await?;
let rendered_mail = render_queued_mail(
queued_mail,
&public_key,
&secret_key,
from_addr,
side_effects,
)?;
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)
}
@@ -1361,7 +1339,8 @@ impl MimeFactory {
pub(crate) async fn into_queued_mail(
mut self,
context: &Context,
) -> Result<(QueuedMail, RenderSideEffects)> {
bcc_self: bool,
) -> Result<ToBeQueuedMail> {
let rfc724_mid = match &self.loaded {
Loaded::Message { msg, .. } => match &self.pre_message_mode {
PreMessageMode::Pre { .. } => {
@@ -1383,9 +1362,7 @@ impl MimeFactory {
let is_encrypted = self.will_be_encrypted();
let last_added_location_timestamp;
let avatar_is_attached;
let sync_ids_to_delete;
let side_effects: Option<QueueSideEffects>;
let message: MimePart<'static> = match &self.loaded {
Loaded::Message { msg, .. } => {
@@ -1393,15 +1370,21 @@ impl MimeFactory {
let RenderedMessage {
main_part,
mut parts,
last_added_location_timestamp: tmp_last_added_location_timestamp,
avatar_is_attached: tmp_avatar_is_attached,
sync_ids_to_delete: tmp_sync_ids_to_delete,
last_added_location_timestamp,
avatar_is_attached,
sync_ids_to_delete,
} = self
.render_message(context, &mut headers, &grpimage, is_encrypted)
.await?;
last_added_location_timestamp = tmp_last_added_location_timestamp;
avatar_is_attached = tmp_avatar_is_attached;
sync_ids_to_delete = tmp_sync_ids_to_delete;
side_effects = Some(QueueSideEffects {
chat_id: msg.chat_id,
avatar_is_attached,
sync_ids_to_delete,
last_added_location_timestamp,
subject: subject_str,
});
if parts.is_empty() {
// Single part, render as regular message.
main_part
@@ -1419,9 +1402,7 @@ impl MimeFactory {
}
}
Loaded::Mdn { .. } => {
last_added_location_timestamp = None;
avatar_is_attached = false;
sync_ids_to_delete = None;
side_effects = None;
self.render_mdn()?
}
};
@@ -1431,12 +1412,6 @@ impl MimeFactory {
Loaded::Mdn { .. } => self.update_mdn_pubkey_attachment(context).await?,
};
let is_post_message = self.pre_message_mode == PreMessageMode::Post;
let side_effects = RenderSideEffects {
avatar_is_attached,
sync_ids_to_delete,
last_added_location_timestamp,
subject: subject_str,
};
let is_securejoin_message = match &self.loaded {
Loaded::Message { msg, .. } => msg.param.get_cmd() == SystemMessage::SecurejoinMessage,
@@ -1493,17 +1468,6 @@ impl MimeFactory {
.is_none_or(|ts| now >= ts + gossip_period || now < ts)
};
let verifier_id: Option<u32> = context
.sql
.query_get_value(
"SELECT verifier FROM contacts WHERE fingerprint=?",
(&fingerprint,),
)
.await?;
let is_verified =
verifier_id.is_some_and(|verifier_id| verifier_id != 0);
if !should_do_gossip {
continue;
}
@@ -1514,7 +1478,6 @@ impl MimeFactory {
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
verified: is_verified,
}
.to_string();
@@ -1554,7 +1517,7 @@ impl MimeFactory {
let is_mdn = matches!(self.loaded, Loaded::Mdn { .. });
let should_sign = true;
let message = if self.will_be_encrypted() {
let message = if is_encrypted {
add_headers_to_encrypted_part(message, headers)
} else if is_mdn {
// Never add outer multipart/mixed wrapper to MDN
@@ -1590,15 +1553,19 @@ impl MimeFactory {
})
};
let raw_message = part_to_bytes(message);
let recipients = self.recipients();
let queued_email = QueuedMail {
raw_message,
rfc724_mid,
display_name,
encryption: self.encryption,
encryption: self.encryption.into_queued_encryption(),
should_attach_pubkey,
should_sign,
should_compress,
recipients,
sent_to: Vec::new(),
bcc_self,
};
Ok((queued_email, side_effects))
}
@@ -1654,43 +1621,6 @@ impl MimeFactory {
let command = msg.param.get_cmd();
let mut placeholdertext = None;
let send_verified_headers = match chat.typ {
Chattype::Single => true,
Chattype::Group => true,
// Mailinglists and broadcast channels can actually never be verified:
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => false,
};
if send_verified_headers {
let was_protected: bool = context
.sql
.query_get_value("SELECT protected FROM chats WHERE id=?", (chat.id,))
.await?
.unwrap_or_default();
if was_protected {
let unverified_member_exists = context
.sql
.exists(
"SELECT COUNT(*)
FROM contacts, chats_contacts
WHERE chats_contacts.contact_id=contacts.id AND chats_contacts.chat_id=?
AND contacts.id>9
AND contacts.verifier=0",
(chat.id,),
)
.await?;
if !unverified_member_exists {
headers.push((
"Chat-Verified",
mail_builder::headers::raw::Raw::new("1").into(),
));
}
}
}
if chat.typ == Chattype::Group {
// Send group ID unless it is an ad hoc group that has no ID.
if !chat.grpid.is_empty() {
@@ -2277,19 +2207,16 @@ impl MimeFactory {
}
pub fn will_be_encrypted(&self) -> bool {
match self.encryption {
Encryption::No => false,
Encryption::Asymmetric { .. } | Encryption::Symmetric { .. } => true,
}
self.encryption.is_encrypted()
}
pub fn set_as_post_message(&mut self) {
self.pre_message_mode = PreMessageMode::Post;
}
pub fn set_as_pre_message_for(&mut self, post_message: &RenderedEmail) {
pub fn set_as_pre_message_for(&mut self, rfc724_mid: &str) {
self.pre_message_mode = PreMessageMode::Pre {
post_msg_rfc724_mid: post_message.rfc724_mid.clone(),
post_msg_rfc724_mid: rfc724_mid.to_string(),
};
}
}
@@ -2467,29 +2394,15 @@ async fn non_chat_headers(
Ok(headers)
}
/// Renders `queued_mail` for SMTP with the own key pair and primary address.
async fn render_with_self_key(context: &Context, queued_mail: QueuedMail) -> Result<String> {
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let from_addr = context.get_primary_self_addr().await?;
let rendered_mail = render_queued_mail(
queued_mail,
&public_key,
&secret_key,
from_addr,
RenderSideEffects::default(),
)?;
Ok(rendered_mail.message)
}
pub(crate) async fn render_symm_encrypted_securejoin_message(
pub(crate) async fn symm_encrypted_securejoin_message(
context: &Context,
step: &str,
rfc724_mid: &str,
should_attach_pubkey: bool,
auth: &str,
shared_secret: &str,
) -> Result<String> {
recipients: Vec<String>,
) -> Result<QueuedMail> {
info!(context, "Sending secure-join message {step:?}.");
let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join");
@@ -2504,7 +2417,7 @@ pub(crate) async fn render_symm_encrypted_securejoin_message(
raw_message: part_to_bytes(message),
display_name: String::new(),
rfc724_mid: rfc724_mid.to_string(),
encryption: Encryption::Symmetric {
encryption: QueuedEncryption::Symmetric {
shared_secret: shared_secret.to_string(),
},
should_attach_pubkey,
@@ -2514,9 +2427,13 @@ pub(crate) async fn render_symm_encrypted_securejoin_message(
// there are no compression side channels
// leaking information about the tokens.
should_compress: false,
recipients,
sent_to: Vec::new(),
// Never send a copy of SecureJoin message to self.
bcc_self: false,
};
render_with_self_key(context, queued_mail).await
Ok(queued_mail)
}
/// Returns the body of a keyupdate message, shaped like a receipt notification.
@@ -2545,13 +2462,14 @@ fn keyupdate_body() -> MimePart<'static> {
)
}
/// Renders a keyupdate message informing the owners of `recipient_keys`
/// Returns a keyupdate message informing the owners of `recipient_keys`
/// about the current key and relay list, see [`crate::keyupdate`].
pub(crate) async fn render_keyupdate_message(
pub(crate) async fn keyupdate_message(
context: &Context,
rfc724_mid: &str,
recipient_keys: Vec<SignedPublicKey>,
) -> Result<String> {
recipients: Vec<String>,
) -> Result<QueuedMail> {
info!(
context,
"Sending keyupdate message to {} recipients.",
@@ -2566,11 +2484,8 @@ pub(crate) async fn render_keyupdate_message(
raw_message: part_to_bytes(message),
display_name: String::new(),
rfc724_mid: rfc724_mid.to_string(),
encryption: Encryption::Asymmetric {
encryption_pubkeys: recipient_keys
.into_iter()
.map(|key| (String::new(), key))
.collect(),
encryption: QueuedEncryption::Asymmetric {
encryption_pubkeys: recipient_keys,
},
// Attached key with its relay list notation is the actual payload.
@@ -2582,9 +2497,11 @@ pub(crate) async fn render_keyupdate_message(
// Disable compression to avoid side channels, message body is small anyway.
should_compress: false,
recipients,
sent_to: Vec::new(),
bcc_self: false,
};
render_with_self_key(context, queued_mail).await
Ok(queued_mail)
}
/// Renders MIME part into a vector of bytes.
+156 -32
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;
@@ -306,9 +306,10 @@ async fn test_mdn_create_encrypted() -> Result<()> {
message::markseen_msgs(&bob, vec![rcvd.id]).await?;
let mimefactory =
MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
let rendered_msg = mimefactory.render(&bob).await?;
assert!(!mimefactory.will_be_encrypted());
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
assert!(!rendered_msg.is_encrypted);
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;
@@ -319,9 +320,10 @@ async fn test_mdn_create_encrypted() -> Result<()> {
message::markseen_msgs(&bob, vec![rcvd.id]).await?;
let mimefactory = MimeFactory::from_mdn(&bob, rcvd.from_id, rcvd.rfc724_mid, vec![]).await?;
let rendered_msg = mimefactory.render(&bob).await?;
assert!(mimefactory.will_be_encrypted());
let bob_addr = bob.get_primary_self_addr().await?;
let rendered_msg = mimefactory.render(&bob, &bob_addr).await?;
assert!(rendered_msg.is_encrypted);
assert!(!rendered_msg.message.contains("Bob Examplenet"));
assert!(!rendered_msg.message.contains("Alice Exampleorg"));
@@ -363,7 +365,8 @@ async fn test_mdn_autocrypt_throttle() -> Result<()> {
rcvd: &Message,
) -> Result<bool> {
let mf = MimeFactory::from_mdn(bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?;
let rendered_msg = mf.render(bob).await?;
let addr = bob.get_primary_self_addr().await?;
let rendered_msg = mf.render(bob, &addr).await?;
let mime = MimeMessage::from_bytes(alice, rendered_msg.message.as_bytes()).await?;
Ok(mime.autocrypt_fingerprint.is_some())
}
@@ -644,7 +647,8 @@ async fn test_render_reply() {
let recipients = mimefactory.recipients();
assert_eq!(recipients, vec!["charlie@example.net"]);
let rendered_msg = mimefactory.render(t).await.unwrap();
let addr = t.get_primary_self_addr().await.unwrap();
let rendered_msg = mimefactory.render(t, &addr).await.unwrap();
let mail = mailparse::parse_mail(rendered_msg.message.as_bytes()).unwrap();
assert_eq!(
@@ -709,9 +713,9 @@ async fn test_remove_member_bcc() -> Result<()> {
let charlie = &tcm.charlie().await;
alice.allow_unencrypted().await?;
let alice_addr = alice.get_config(Config::Addr).await?.unwrap();
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
let alice_addr = alice.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_addr = bob.get_config(Config::ConfiguredAddr).await?.unwrap();
let charlie_addr = charlie.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_id = alice.add_or_lookup_address_contact_id(bob).await;
let charlie_id = alice.add_or_lookup_address_contact_id(charlie).await;
@@ -874,7 +878,7 @@ async fn test_new_member_is_first_recipient() -> Result<()> {
assert!(
sent_msg
.recipients
.starts_with(&charlie.get_config(Config::Addr).await?.unwrap())
.starts_with(&charlie.get_config(Config::ConfiguredAddr).await?.unwrap())
);
remove_contact_from_chat(alice, group, bob_id).await?;
@@ -885,7 +889,7 @@ async fn test_new_member_is_first_recipient() -> Result<()> {
assert!(
sent_msg
.recipients
.starts_with(&bob.get_config(Config::Addr).await?.unwrap())
.starts_with(&bob.get_config(Config::ConfiguredAddr).await?.unwrap())
);
Ok(())
}
@@ -1018,7 +1022,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;
@@ -1026,26 +1030,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
@@ -1081,3 +1068,140 @@ 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
Chat-Disposition-Notification-To: alice@example.org
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", "content".as_bytes(), 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
Chat-Disposition-Notification-To: alice@example.org
--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
}
+16 -33
View File
@@ -34,17 +34,6 @@ use crate::sync::SyncItems;
use crate::tools::{get_filemeta, parse_receive_headers, time, truncate_msg_text, validate_id};
use crate::{chatlist_events, location, tools};
/// Public key extracted from `Autocrypt-Gossip`
/// header with associated information.
#[derive(Debug)]
pub struct GossipedKey {
/// Public key extracted from `keydata` attribute.
pub public_key: SignedPublicKey,
/// True if `Autocrypt-Gossip` has a `_verified` attribute.
pub verified: bool,
}
/// A parsed MIME message.
///
/// This represents the relevant information of a parsed MIME message
@@ -99,7 +88,7 @@ pub(crate) struct MimeMessage {
/// The addresses for which there was a gossip header
/// and their respective gossiped keys.
pub gossiped_keys: BTreeMap<String, GossipedKey>,
pub gossiped_keys: BTreeMap<String, SignedPublicKey>,
/// Fingerprint of the key in the Autocrypt header.
///
@@ -1048,7 +1037,7 @@ impl MimeMessage {
};
self.gossiped_keys
.values()
.map(|gossiped_key| gossiped_key.public_key.dc_fingerprint().hex())
.map(|gossiped_key| gossiped_key.dc_fingerprint().hex())
.chain(sender_fingerprint)
.collect()
}
@@ -2118,9 +2107,9 @@ fn remove_header(
async fn parse_gossip_headers(
context: &Context,
gossip_headers: Vec<String>,
) -> Result<BTreeMap<String, GossipedKey>> {
) -> Result<BTreeMap<String, SignedPublicKey>> {
// XXX split the parsing from the modification part
let mut gossiped_keys: BTreeMap<String, GossipedKey> = Default::default();
let mut gossiped_keys: BTreeMap<String, SignedPublicKey> = Default::default();
for value in &gossip_headers {
let header = match Aheader::from_str(value) {
@@ -2135,12 +2124,7 @@ async fn parse_gossip_headers(
.await
.context("Failed to import Autocrypt-Gossip key")?;
let gossiped_key = GossipedKey {
public_key: header.public_key,
verified: header.verified,
};
gossiped_keys.insert(header.addr.to_lowercase(), gossiped_key);
gossiped_keys.insert(header.addr.to_lowercase(), header.public_key);
}
Ok(gossiped_keys)
@@ -2556,19 +2540,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(())
@@ -131,7 +131,7 @@ async fn test_broadcast_security_attacker_signature() -> Result<()> {
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
let charlie_addr = charlie.get_config(Config::ConfiguredAddr).await?.unwrap();
test_shared_secret_decryption_ext(
bob,
@@ -183,10 +183,7 @@ async fn test_broadcast_security_happy_path() -> Result<()> {
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
let alice_addr = alice
.get_config(crate::config::Config::Addr)
.await?
.unwrap();
let alice_addr = alice.get_config(Config::ConfiguredAddr).await?.unwrap();
test_shared_secret_decryption_ext(bob, &alice_addr, &secret, Some(alice), None).await
}
@@ -205,7 +202,7 @@ async fn test_qr_code_security() -> Result<()> {
// Start a securejoin process, but don't finish it:
join_securejoin(bob, &qr).await?;
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
let charlie_addr = charlie.get_config(Config::ConfiguredAddr).await?.unwrap();
let alice_fp = self_fingerprint(alice).await?;
let secret_for_encryption = format!("securejoin/{alice_fp}/{authcode}");
+1 -1
View File
@@ -33,7 +33,7 @@ use tls::wrap_tls;
pub(crate) const TIMEOUT: Duration = Duration::from_secs(60);
/// TTL for caches in seconds.
pub(crate) const CACHE_TTL: u64 = 30 * 24 * 60 * 60;
pub(crate) const CACHE_TTL: u32 = 30 * 24 * 60 * 60;
/// Removes connection history entries after `CACHE_TTL`.
pub(crate) async fn prune_connection_history(context: &Context) -> Result<()> {
+4
View File
@@ -2,6 +2,7 @@ use std::sync::LazyLock;
use tokio::sync::OnceCell;
use super::*;
use crate::transport::add_pseudo_transport;
use crate::{
config::Config,
decrypt,
@@ -19,6 +20,9 @@ async fn decrypt_bytes(
auth_tokens_for_decryption: &[String],
) -> Result<pgp::composed::Message<'static>> {
let t = &TestContext::new().await;
add_pseudo_transport(t, "alice@example.org")
.await
.expect("Failed to add pseudo transport");
t.set_config(Config::ConfiguredAddr, Some("alice@example.org"))
.await
.expect("Failed to configure address");
+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(())
}
+21 -33
View File
@@ -13,6 +13,7 @@ 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;
@@ -45,7 +46,7 @@ pub(crate) const DCBACKUP_VERSION: i32 = 5;
/// Scanned QR code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Qr {
/// Ask the user whether to verify the contact.
/// Ask the user whether to start chatting with the contact.
///
/// If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`].
AskVerifyContact {
@@ -125,7 +126,7 @@ pub enum Qr {
is_v3: bool,
},
/// Contact fingerprint is verified.
/// Contact fingerprint matches.
///
/// Ask the user if they want to start chatting.
FprOk {
@@ -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 })
}
}
@@ -838,7 +825,8 @@ pub(crate) async fn login_param_from_account_qr(
.context("Invalid DCACCOUNT scheme")?;
if !payload.starts_with(HTTPS_SCHEME) {
let param = login_param_from_host(payload);
let mark_as_autorelay = false;
let param = login_param_from_host(payload, mark_as_autorelay);
return Ok(param);
}
+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;
+4 -4
View File
@@ -85,15 +85,15 @@ pub fn create_qr_svg(qrcode_content: &str) -> Result<String> {
Ok(svg)
}
/// Returns SVG of the QR code to join the group or verify contact.
/// Returns SVG of the QR code to join the group or set up a contact.
///
/// If `chat_id` is `None`, returns verification QR code.
/// If `chat_id` is `None`, returns setup contact QR code.
/// Otherwise, returns secure join QR code.
pub async fn get_securejoin_qr_svg(context: &Context, chat_id: Option<ChatId>) -> Result<String> {
if let Some(chat_id) = chat_id {
generate_join_group_qr_code(context, chat_id).await
} else {
generate_verification_qr(context).await
generate_setup_contact_qr(context).await
}
}
@@ -127,7 +127,7 @@ async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Resu
)
}
async fn generate_verification_qr(context: &Context) -> Result<String> {
async fn generate_setup_contact_qr(context: &Context) -> Result<String> {
let (avatar, displayname, addr, color) = self_info(context).await?;
inner_generate_secure_join_qr_code(
+2 -2
View File
@@ -84,7 +84,7 @@ pub struct ReactionFrequency {
pub reaction: Reaction,
/// Number of contacts that reacted with this emoji.
pub count: usize,
pub count: u32,
/// True if `ContactId::SELF` is among the contacts that reacted with this emoji.
pub is_from_self: bool,
@@ -420,7 +420,7 @@ pub(crate) async fn apply_pending_reactions(
/// sorted in descending order of frequency.
fn calc_frequencies(by_contact: &BTreeMap<ContactId, Reaction>) -> Vec<ReactionFrequency> {
let mut self_reaction = Reaction::new("");
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
for (contact_id, reaction) in by_contact {
let count = counts.entry(reaction.as_str()).or_insert(0);
*count = count.saturating_add(1);
+4 -4
View File
@@ -42,7 +42,7 @@ struct WireMessage {
#[derive(Debug, Serialize, Deserialize)]
struct WireEntry {
emoji: String,
count: usize,
count: u32,
}
/// Renders one or more message's states as a JSON string, ready to be sent in `Chat-Broadcast-States:` header.
@@ -235,10 +235,10 @@ pub(crate) async fn load_broadcast_reactions(
(msg_id,),
|row| {
let reaction: String = row.get(0)?;
let count: i64 = row.get(1)?;
let count: u32 = row.get(1)?;
Ok(ReactionFrequency {
reaction: Reaction::new(&reaction),
count: count as usize,
count,
is_from_self: false,
})
},
@@ -405,7 +405,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_modify_frequencies() {
// Helper to create a ReactionFrequency entry
let freq = |emoji: &str, count: usize, is_from_self: bool| -> ReactionFrequency {
let freq = |emoji: &str, count: u32, is_from_self: bool| -> ReactionFrequency {
ReactionFrequency {
reaction: Reaction::new(emoji),
count,
+34 -117
View File
@@ -19,7 +19,7 @@ use crate::chat::{
};
use crate::config::Config;
use crate::constants::{Blocked, Chattype, EDITED_PREFIX};
use crate::contact::{self, Contact, ContactId, Origin, mark_contact_id_as_verified};
use crate::contact::{self, Contact, ContactId, Origin};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc_inner;
use crate::download::{DownloadState, msg_is_downloaded_for};
@@ -27,7 +27,7 @@ use crate::ephemeral::{Timer as EphemeralTimer, stock_ephemeral_timer_changed};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table};
use crate::key::{DcKey, Fingerprint};
use crate::key::{DcKey, Fingerprint, SignedPublicKey};
use crate::key::{
load_self_public_key, load_self_public_key_opt, self_fingerprint, self_fingerprint_opt,
};
@@ -36,7 +36,7 @@ use crate::message::{
self, Message, MessageState, MsgId, Viewtype, insert_tombstone, rfc724_mid_exists,
};
use crate::mimeparser::{
AvatarAction, GossipedKey, MimeMessage, PreMessageMode, SystemMessage, parse_message_ids,
AvatarAction, MimeMessage, PreMessageMode, SystemMessage, parse_message_ids,
};
use crate::param::{Param, Params};
use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub, iroh_topic_from_str};
@@ -568,16 +568,12 @@ pub(crate) async fn receive_imf_inner(
//
// Note that messages with long recipient lists are sent out in chunks,
// removing already sent recipients from the job after each chunk.
// Self recipients are added at the end so removing the job
// removes the last chunk which apparently went out fine.
let self_addr = context.get_primary_self_addr().await?;
// Self recipients are sent in the end,
// so if we received a copy, the message has been sent out
// to all recipients.
context
.sql
.execute(
"DELETE FROM smtp \
WHERE rfc724_mid=?1 AND (recipients LIKE ?2 OR recipients LIKE ('% ' || ?2))",
(rfc724_mid_orig, &self_addr),
)
.execute("DELETE FROM smtp2 WHERE rfc724_mid=?", (rfc724_mid_orig,))
.await?;
if !msg_has_pending_smtp_job(context, msg_id).await? {
msg_id.set_delivered(context).await?;
@@ -658,7 +654,6 @@ pub(crate) async fn receive_imf_inner(
)
})?
} else if let Some(to_id) = to_ids.first().copied().flatten() {
// handshake may mark contacts as verified and must be processed before chats are created
observe_securejoin_on_other_device(context, &mime_parser, to_id)
.await
.with_context(|| {
@@ -691,12 +686,6 @@ pub(crate) async fn receive_imf_inner(
received_msg = None;
}
let verified_encryption = has_verified_encryption(context, &mime_parser, from_id).await?;
if verified_encryption == VerifiedEncryption::Verified {
mark_recipients_as_verified(context, from_id, &mime_parser).await?;
}
let is_old_contact_request;
let received_msg = if let Some(received_msg) = received_msg {
is_old_contact_request = false;
@@ -2118,7 +2107,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,
@@ -2402,6 +2392,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();
@@ -2426,8 +2417,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(())
}
@@ -2716,6 +2716,12 @@ async fn lookup_or_create_adhoc_group(
for &id in &contact_ids {
stmt.execute((id,)).context("INSERT INTO temp.contacts")?;
}
// Contact IDs are 32-bit internally,
// so this conversion of contact ID set size
// to u32 should never fail.
let contact_ids_len = u32::try_from(contact_ids.len())?;
let val = t
.query_row(
"SELECT c.id, c.blocked
@@ -2729,7 +2735,7 @@ async fn lookup_or_create_adhoc_group(
AND contact_id NOT IN (SELECT id FROM temp.contacts)
AND add_timestamp >= remove_timestamp)=0
ORDER BY m.timestamp DESC",
(&grpname, contact_ids.len()),
(&grpname, contact_ids_len),
|row| {
let id: ChatId = row.get(0)?;
let blocked: Blocked = row.get(1)?;
@@ -2996,7 +3002,7 @@ struct GroupChangesInfo {
extra_msgs: Vec<(String, SystemMessage, Option<ContactId>)>,
}
/// Apply group member list, name, avatar and protection status changes from the MIME message.
/// Apply group member list, name and avatar changes from the MIME message.
///
/// Returns [GroupChangesInfo].
///
@@ -3064,7 +3070,7 @@ async fn apply_group_changes(
// just like we look at ChatGroupMemberRemovedFpr.
// The result of the error is that info message
// may contain display name of the wrong contact.
let fingerprint = key.public_key.dc_fingerprint().hex();
let fingerprint = key.dc_fingerprint().hex();
if let Some(contact_id) =
lookup_key_contact_by_fingerprint(context, &fingerprint).await?
{
@@ -3743,10 +3749,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,
@@ -3754,11 +3759,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 {
@@ -3971,89 +3971,6 @@ async fn create_adhoc_group(
Ok(Some((new_chat_id, create_blocked)))
}
#[derive(Debug, PartialEq, Eq)]
enum VerifiedEncryption {
Verified,
NotVerified(String), // The string contains the reason why it's not verified
}
/// Checks whether the message is allowed to appear in a protected chat.
///
/// This means that it is encrypted and signed with a verified key.
async fn has_verified_encryption(
context: &Context,
mimeparser: &MimeMessage,
from_id: ContactId,
) -> Result<VerifiedEncryption> {
use VerifiedEncryption::*;
if !mimeparser.was_encrypted() {
return Ok(NotVerified("This message is not encrypted".to_string()));
};
if from_id == ContactId::SELF {
return Ok(Verified);
}
let from_contact = Contact::get_by_id(context, from_id).await?;
let Some(fingerprint) = from_contact.fingerprint() else {
return Ok(NotVerified(
"The message was sent without encryption".to_string(),
));
};
if from_contact.get_verifier_id(context).await?.is_none() {
return Ok(NotVerified(
"The message was sent by non-verified contact".to_string(),
));
}
let signed_with_verified_key = mimeparser
.signature
.as_ref()
.is_some_and(|(signature, _)| *signature == fingerprint);
if signed_with_verified_key {
Ok(Verified)
} else {
Ok(NotVerified(
"The message was sent with non-verified encryption".to_string(),
))
}
}
async fn mark_recipients_as_verified(
context: &Context,
from_id: ContactId,
mimeparser: &MimeMessage,
) -> Result<()> {
let verifier_id = Some(from_id).filter(|&id| id != ContactId::SELF);
// We don't yet send the _verified property in autocrypt headers.
// Until we do, we instead accept the Chat-Verified header as indication all contacts are verified.
// TODO: Ignore ChatVerified header once we reset existing verifications.
let chat_verified = mimeparser.get_header(HeaderDef::ChatVerified).is_some();
for gossiped_key in mimeparser
.gossiped_keys
.values()
.filter(|gossiped_key| gossiped_key.verified || chat_verified)
{
let fingerprint = gossiped_key.public_key.dc_fingerprint().hex();
let Some(to_id) = lookup_key_contact_by_fingerprint(context, &fingerprint).await? else {
continue;
};
if to_id == ContactId::SELF || to_id == from_id {
continue;
}
mark_contact_id_as_verified(context, to_id, verifier_id).await?;
}
Ok(())
}
/// Returns the last message referenced from `References` header if it is in the database.
///
/// For Delta Chat messages it is the last message in the chat of the sender.
@@ -4121,7 +4038,7 @@ async fn add_or_lookup_contacts_by_address_list(
async fn add_or_lookup_key_contacts(
context: &Context,
address_list: &[SingleInfo],
gossiped_keys: &BTreeMap<String, GossipedKey>,
gossiped_keys: &BTreeMap<String, SignedPublicKey>,
fingerprints: &[Fingerprint],
origin: Origin,
) -> Result<Vec<Option<ContactId>>> {
@@ -4138,7 +4055,7 @@ async fn add_or_lookup_key_contacts(
// Iterator has not ran out of fingerprints yet.
fp.hex()
} else if let Some(key) = gossiped_keys.get(addr) {
key.public_key.dc_fingerprint().hex()
key.dc_fingerprint().hex()
} else if context.is_self_addr(addr).await? {
contact_ids.push(Some(ContactId::SELF));
continue;
+70 -153
View File
@@ -15,11 +15,11 @@ use crate::headerdef::HeaderDefMap as _;
use crate::imap::prefetch_should_download;
use crate::imex::{ImexMode, imex};
use crate::key;
use crate::mimefactory;
use crate::securejoin::get_securejoin_qr;
use crate::smtp;
use crate::test_utils;
use crate::test_utils::{
TestContext, TestContextManager, alice_keypair, get_chat_msg, mark_as_verified,
};
use crate::test_utils::{TestContext, TestContextManager, alice_keypair, get_chat_msg};
use crate::tools::{SystemTime, time};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -726,6 +726,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 +759,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 +767,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,19 +2723,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 rendered_mdn = mdn_mimefactory.render(&bob).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?;
@@ -2760,19 +2746,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 rendered_mdn = mdn_mimefactory.render(bob).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;
@@ -3164,13 +3138,11 @@ async fn test_auto_accept_group_for_bots() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_auto_accept_protected_group_for_bots() -> Result<()> {
async fn test_auto_accept_encrypted_group_for_bots() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config(Config::Bot, Some("1")).await.unwrap();
mark_as_verified(alice, bob).await;
mark_as_verified(bob, alice).await;
let group_id = alice.create_group_with_members("Group", &[bob]).await;
let sent = alice.send_text(group_id, "Hello!").await;
let msg = bob.recv_msg(&sent).await;
@@ -3180,21 +3152,13 @@ async fn test_auto_accept_protected_group_for_bots() -> Result<()> {
}
/// Regression test for a bug where receive_imf() failed
/// if the sender of a verification-gossiping message
/// if the sender of a gossiping message
/// also put itself into the To header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_verification_gossip() -> Result<()> {
async fn test_gossip_sender_in_to_header() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
mark_as_verified(alice, bob).await;
mark_as_verified(bob, alice).await;
// This is message sent by Alice with verified encryption
// that gossips Fiona's verification,
// and for some reason, Alice also put herself into the To: header.
let imf_raw =
include_bytes!("../../test-data/message/verification-gossip-also-sent-to-from.eml");
@@ -3202,12 +3166,6 @@ async fn test_verification_gossip() -> Result<()> {
let msg = receive_imf(bob, imf_raw, false).await?.unwrap();
let msg = Message::load_from_db(bob, msg.msg_ids[0]).await?;
assert_eq!(msg.text, "Hello!");
assert!(
bob.add_or_lookup_contact(fiona)
.await
.is_verified(bob)
.await?
);
Ok(())
}
@@ -3380,6 +3338,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;
@@ -3863,7 +3850,7 @@ async fn test_unsigned_chat_group_hdr() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
let bob_addr = bob.get_config(Config::ConfiguredAddr).await?.unwrap();
let bob_id = alice.add_or_lookup_contact_id(bob).await;
let alice_chat_id = create_group(alice, "foos").await?;
add_contact_to_chat(alice, alice_chat_id, bob_id).await?;
@@ -4551,7 +4538,7 @@ async fn test_outgoing_msg_forgery() -> Result<()> {
let mut tcm = TestContextManager::new();
let export_dir = tempfile::tempdir().unwrap();
let alice = &tcm.alice().await;
let alice_addr = &alice.get_config(Config::Addr).await?.unwrap();
let alice_addr = &alice.get_config(Config::ConfiguredAddr).await?.unwrap();
imex(alice, ImexMode::ExportSelfKeys, export_dir.path(), None).await?;
// We need Bob only to encrypt the forged message to Alice's key, actually Bob doesn't
// participate in the scenario.
@@ -4626,11 +4613,10 @@ async fn test_pre_msg_group_consistency() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_protected_group_add_remove_member_missing_key() -> Result<()> {
async fn test_group_add_remove_member_missing_key() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
mark_as_verified(alice, bob).await;
let group_id = create_group(alice, "Group").await?;
let alice_bob_id = alice.add_or_lookup_contact(bob).await.id;
add_contact_to_chat(alice, group_id, alice_bob_id).await?;
@@ -4638,7 +4624,6 @@ async fn test_protected_group_add_remove_member_missing_key() -> Result<()> {
alice.sql.execute("DELETE FROM public_keys", ()).await?;
let fiona = &tcm.fiona().await;
mark_as_verified(alice, fiona).await;
let alice_fiona_id = alice.add_or_lookup_contact(fiona).await.id;
add_contact_to_chat(alice, group_id, alice_fiona_id).await?;
@@ -5131,7 +5116,7 @@ async fn test_rename_chat_after_creating_invite() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unverified_member_msg() -> Result<()> {
async fn test_member_msg_downloaded() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
@@ -5146,97 +5131,12 @@ async fn test_unverified_member_msg() -> Result<()> {
let fiona_chat_id = fiona.get_last_msg().await.chat_id;
let fiona_sent_msg = fiona.send_text(fiona_chat_id, "Hi").await;
// The message is by non-verified member,
// but the checks have been removed
// and the message should be downloaded as usual.
let bob_msg = bob.recv_msg(&fiona_sent_msg).await;
assert_eq!(bob_msg.download_state, DownloadState::Done);
assert_eq!(bob_msg.text, "Hi");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_reverify_by_self_on_outgoing_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let a0 = &tcm.alice().await;
let a1 = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let bob_chat_id = chat::create_group(bob, "Group").await?;
bob.set_chat_protected(bob_chat_id).await;
let qr = get_securejoin_qr(bob, Some(bob_chat_id)).await?;
tcm.exec_securejoin_qr(fiona, bob, &qr).await;
tcm.exec_securejoin_qr(a0, bob, &qr).await;
tcm.exec_securejoin_qr(a1, bob, &qr).await;
// Shift time by one week to trigger gossip.
SystemTime::shift(Duration::from_secs(7 * 24 * 3600));
let a0_chat_id = a0.get_last_msg().await.chat_id;
let a0_sent_msg = a0.send_text(a0_chat_id, "Hi").await;
a1.recv_msg(&a0_sent_msg).await;
let a1_bob_id = a1.add_or_lookup_contact_id(bob).await;
let a1_fiona = a1.add_or_lookup_contact(fiona).await;
assert_eq!(
a1_fiona.get_verifier_id(a1).await?.unwrap().unwrap(),
a1_bob_id
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_verify_by_verified_by_unknown() -> Result<()> {
let mut tcm = TestContextManager::new();
let a0 = &tcm.alice().await;
let a1 = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let bob_chat_id = chat::create_group(bob, "Group").await?;
bob.set_chat_protected(bob_chat_id).await;
let qr = get_securejoin_qr(bob, Some(bob_chat_id)).await?;
tcm.exec_securejoin_qr(a0, bob, &qr).await;
let qr = get_securejoin_qr(bob, None).await?;
tcm.exec_securejoin_qr(fiona, bob, &qr).await;
// Bob verifies Fiona for Alice#0.
let bob_fiona_id = bob.add_or_lookup_contact_id(fiona).await;
add_contact_to_chat(bob, bob_chat_id, bob_fiona_id).await?;
let sent_msg = bob.pop_sent_msg().await;
a0.recv_msg(&sent_msg).await;
fiona.recv_msg(&sent_msg).await;
let a0_bob = a0.add_or_lookup_contact(bob).await;
let a0_fiona = a0.add_or_lookup_contact(fiona).await;
assert_eq!(a0_fiona.get_verifier_id(a0).await?, Some(Some(a0_bob.id)));
let chat_id = a0.create_group_with_members("group", &[fiona]).await;
a0.set_chat_protected(chat_id).await;
a1.recv_msg(&a0.send_text(chat_id, "Hi").await).await;
let a1_fiona = a1.add_or_lookup_contact(fiona).await;
assert_eq!(a1_fiona.get_verifier_id(a1).await?, Some(None));
let some_time_to_regossip = Duration::from_secs(20 * 24 * 3600);
SystemTime::shift(some_time_to_regossip);
let fiona_chat_id = fiona.get_last_msg().await.chat_id;
fiona.set_chat_protected(fiona_chat_id).await;
a1.recv_msg(&fiona.send_text(fiona_chat_id, "Hi").await)
.await;
let a1_bob = a1.add_or_lookup_contact(bob).await;
// There was a bug that Bob is verified by Fiona on Alice's other device.
assert_eq!(a1_bob.get_verifier_id(a1).await?, Some(None));
SystemTime::shift(some_time_to_regossip);
tcm.execute_securejoin(a1, fiona).await;
a1.recv_msg(&fiona.send_text(fiona_chat_id, "Hi").await)
.await;
// But now Bob's verifier id must be updated because Fiona is verified by a known verifier
// (moreover, directly), so Alice has reverse verification chains on her devices.
assert_eq!(a1_bob.get_verifier_id(a1).await?, Some(Some(a1_fiona.id)));
Ok(())
}
/// Tests that second device assigns outgoing encrypted messages
/// to a single chat with key-contact even if the key of the contact is unknown.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -5735,7 +5635,7 @@ async fn test_bcc_not_a_group() -> Result<()> {
async fn test_lookup_key_contact_by_address_self() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let addr = &t.get_config(Config::Addr).await?.unwrap();
let addr = &t.get_config(Config::ConfiguredAddr).await?.unwrap();
assert_eq!(
lookup_key_contact_by_address(t, addr, None).await?,
Some(ContactId::SELF)
@@ -5788,7 +5688,7 @@ async fn test_outgoing_determined_by_signature() -> Result<()> {
alice_dev2.configure_addr(different_from).await;
key::store_self_keypair(alice_dev2, &alice_keypair()).await?;
assert_ne!(
alice.get_config(Config::Addr).await?.unwrap(),
alice.get_config(Config::ConfiguredAddr).await?.unwrap(),
different_from
);
@@ -5847,15 +5747,32 @@ async fn test_mark_message_as_delivered_only_after_sent_out_fully() -> Result<()
/// This simulates the case that a message is successfully sent out,
/// but the 'OK' answer from the server doesn't arrive,
/// so that the SMTP row stays in the database.
pub(crate) async fn first_row_in_smtp_queue(alice: &TestContext) -> (MsgId, String) {
alice
pub(crate) async fn first_row_in_smtp_queue(context: &TestContext) -> (MsgId, String) {
let (rowid, msg_id) = context
.sql
.query_row_optional("SELECT msg_id, mime FROM smtp ORDER BY id", (), |row| {
let msg_id: MsgId = row.get(0)?;
let mime: String = row.get(1)?;
Ok((msg_id, mime))
})
.query_row_optional(
"SELECT id, msg_id FROM smtp2 ORDER BY id LIMIT 1",
(),
|row| {
let rowid: i64 = row.get(0)?;
let msg_id: MsgId = row.get(1)?;
Ok((rowid, msg_id))
},
)
.await
.expect("query_row_optional failed")
.expect("No SMTP row found")
.expect("No SMTP row found");
let query_only = true;
let queued_mail = context
.sql
.transaction_ext(query_only, |transaction| {
smtp::queue::load_queued_mail(transaction, rowid)
})
.await
.unwrap();
let rendered_mail = mimefactory::render_queued_mail_with_context(queued_mail, context)
.await
.unwrap();
(msg_id, rendered_mail.message)
}
+67 -12
View File
@@ -9,7 +9,7 @@ use async_channel::{self as channel, Receiver, Sender};
use futures::future::try_join_all;
use futures_lite::FutureExt;
use tokio::sync::{RwLock, oneshot};
use tokio::task;
use tokio::task::{self, JoinSet};
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
@@ -215,10 +215,17 @@ impl SchedulerState {
/// Indicate that the network likely has come back.
pub(crate) async fn maybe_network(&self) {
self.interrupt_inbox_idle().await;
self.interrupt_smtp().await;
}
/// Interrupts IDLE on all transports so that they fetch,
/// and marks them as having work to do.
pub(crate) async fn interrupt_inbox_idle(&self) {
let inner = self.inner.read().await;
let inboxes = match *inner {
InnerSchedulerState::Started(ref scheduler) => {
scheduler.maybe_network();
scheduler.interrupt_inbox();
scheduler
.inboxes
.iter()
@@ -282,6 +289,60 @@ impl SchedulerState {
scheduler.interrupt_recently_seen(contact_id, timestamp);
}
}
/// Fetches from all transports at once, each on a dedicated connection,
/// with I/O paused so that the scheduler does not connect as well.
///
/// Returns as soon as one transport fetched messages:
/// the others then fetch nothing more and are dropped,
/// so that a caller woken up by a push notification
/// does not wait for a transport that may never answer.
pub(crate) async fn background_fetch_any(&self, context: &Context) -> Result<()> {
let _pause_guard = self.pause(context).await?;
let stop_token = CancellationToken::new();
let mut set = JoinSet::new();
for (transport_id, param) in ConfiguredLoginParam::load_all(context).await? {
let context = context.clone();
let stop_token = stop_token.clone();
set.spawn(async move {
match background_fetch_from_transport(&context, transport_id, param, stop_token)
.await
{
Ok(fetched) => fetched,
Err(err) => {
warn!(context, "Transport {transport_id}: fetch failed: {err:#}.");
false
}
}
});
}
while let Some(fetched) = set.join_next().await {
if let Ok(true) = fetched {
break;
}
}
Ok(())
}
}
async fn background_fetch_from_transport(
context: &Context,
transport_id: u32,
param: ConfiguredLoginParam,
stop_token: CancellationToken,
) -> Result<bool> {
// A single fetch has nothing to interrupt.
let (_, idle_interrupt_receiver) = channel::bounded(1);
let mut connection = Imap::new(context, transport_id, param, idle_interrupt_receiver).await?;
connection.background_fetch_stop_token = Some(stop_token);
let mut session = connection.prepare(context).await?;
let folder = connection.folder.clone();
connection
.fetch_delete(context, &mut session, &folder)
.await
}
#[derive(Debug, Default)]
@@ -460,7 +521,6 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
};
maybe_broadcast_reactions(ctx).await.log_err(ctx).ok();
maybe_send_stats(ctx).await.log_err(ctx).ok();
session
.update_metadata(ctx)
@@ -473,6 +533,8 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
);
}
maybe_send_stats(ctx).await.log_err(ctx).ok();
let session = fetch_idle(ctx, imap, session).await?;
Ok(session)
}
@@ -494,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)
@@ -745,13 +807,6 @@ impl Scheduler {
self.inboxes.iter()
}
fn maybe_network(&self) {
for b in self.boxes() {
b.conn_state.interrupt();
}
self.interrupt_smtp();
}
fn maybe_network_lost(&self) {
for b in self.boxes() {
b.conn_state.interrupt();

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