Compare commits

..

171 Commits

Author SHA1 Message Date
link2xt
aad9961852 feat: remove show_emails config 2025-12-19 23:35:23 +00:00
link2xt
7bc6f107df test: use Displayname instead of ShowEmails for config cache test
ShowEmails is gonig to be removed.
2025-12-19 23:35:09 +00:00
link2xt
fd90493766 feat: add core version to receive_imf failure message 2025-12-18 14:44:49 +00:00
link2xt
b1883c802b refactor: turn DC_VERSION_STR into &str 2025-12-18 14:44:49 +00:00
link2xt
71ee32b8b7 ci: pin GitHub Action references
Makes `zizmor` happy.
2025-12-18 14:43:27 +00:00
iequidoo
84161f4202 fix: When accepting group, add members with Origin::IncomingTo and sort them down in the contact list (7592)
When accepting a chat, its members are promoted to `Origin::CreateChat`, but for groups it makes
sense to use lower origin because users don't always check all members before accepting a chat and
may not want to have the group members mixed with existing contacts. `IncomingTo` fits here by its
definition: "additional To:'s of incoming message of known sender", i.e. we assume that the sender
of some message is known to the user. This way we can show contacts coming from groups in the bottom
of contact list, maybe even add some separator later. It makes sense not to hide such contacts
completely, otherwise if the user remembers the contact name, but not the chat it's a member of, it
would be difficult to find the contact.
2025-12-16 21:47:05 -03:00
iequidoo
4af9463a91 test: Contact list after accepting group with unknown contacts (#7592)
This records the curent behavior: after accepting a group with unknown contacts the contact list
contains them mixed with contacts for which 1:1 chats exist, i.e. new contacts from the group are
neither hidden nor sorted down.
2025-12-16 21:47:05 -03:00
link2xt
ddd4fc49a2 chore(release): prepare for 2.35.0 2025-12-16 22:22:20 +00:00
link2xt
7d5bedde4d fix: take transport_id into account when using imap table 2025-12-16 22:08:33 +00:00
iequidoo
e34fee72a0 feat: lookup_host_with_cache(): Don't return empty address list (#7596)
All users of this function expect a nonempty list to be returned, otherwise they fail with hard to
understand errors like "No connection attempts were made", so it's better to fail early if DNS
resolution failed and the cache doesn't contain resolutions as well.
2025-12-16 16:03:17 -03:00
link2xt
7ba4a43253 feat: add transport addresses to IMAP URLs in message info 2025-12-16 16:49:49 +00:00
Simon Laux
a09fd4577a fix: add explicit limit for adding relays (5 at the moment) (#7611)
closes https://github.com/chatmail/core/issues/7608
2025-12-15 10:35:23 +00:00
Simon Laux
525a3539d2 misc: log entered login params and actual used params on configuration failure (#7610)
#7587 removed "used_account_settings" and "entered_account_settings"
from Context.get_info(). link2xt pointed out that
entered_account_settings can still be useful to debug login issues, so
tis pr adds logs both on failed configuration attempts.

example warning event:
```
Warning src/configure.rs:292: configure failed: entered params myself@merlinux.eu imap:unset:***:unset:0:Automatic:AUTH_NORMAL smtp:unset:0:unset:0:Automatic:AUTH_NORMAL cert_automatic, used params myself@merlinux.eu imap:[mailcow.testrun.org:993:tls:myself@merlinux.eu, mailcow.testrun.org:143:starttls:myself@merlinux.eu] smtp:[mailcow.testrun.org:465:tls:myself@merlinux.eu, mailcow.testrun.org:587:starttls:myself@merlinux.eu] provider:none cert_automatic
```

---------

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2025-12-15 08:37:45 +00:00
Simon Laux
fbcdd45015 feat: add ip addresses of known public chatmail relays from https://chatmail.at/relays to dns cache (#7607)
closes #7597
2025-12-15 08:29:24 +00:00
iequidoo
1ea8ed6442 fix: Use fallback ICE servers if server can't IMAP METADATA (#7382) 2025-12-14 19:44:11 +00:00
iequidoo
f6817131b8 fix: Don't use fallback servers if got TURN servers from IMAP METADATA 2025-12-14 19:44:10 +00:00
iequidoo
28fc1d2ff2 feat: Use turn.delta.chat as fallback TURN server (#7382) 2025-12-14 19:44:10 +00:00
Simon Laux
5925f72316 fix: remove now redundant "used_account_settings" and "entered_account_settings" from Context.get_info() (#7587)
follow up to https://github.com/chatmail/core/pull/7583
2025-12-13 21:21:55 +00:00
Simon Laux
8dfa5fc37e api: add blob dir size to storage info (#7605)
closes #7598
2025-12-12 20:15:12 +00:00
B. Petersen
49b04e8789 feat: improve error messages on adding relays 2025-12-12 18:28:14 +01:00
link2xt
d87d87f467 fix: do not set normalized name for existing chats and contacts in a migration
We got a report that application is not responding after update
on Android and getting killed before it can start,
suspected to be a slow SQL migration:
<https://github.com/chatmail/core/issues/7602>

This change removes calculation of normalized names for
existing chats and contacts added in
<https://github.com/chatmail/core/pull/7548>
to exclude the possibility of this migration being slow.
New chats and contacts will still get normalized names
and all chats and contacts will get it when they are renamed.
2025-12-12 15:44:51 +00:00
iequidoo
bf72b3ad49 fix: Remove SecurejoinWait info message when received Alice's key (#7585)
And don't add a `SecurejoinWait` info message at all if we know Alice's key from the start. If we
don't remove this info message, it appears in the chat after "Messages are end-to-end encrypted..."
which is quite confusing when Bob can already send messages to Alice.
2025-12-12 04:01:32 -03:00
iequidoo
30f2981259 fix: get_chat_msgs_ex(): Don't match on "S=" (Cmd) in param payload 2025-12-12 04:01:32 -03:00
link2xt
121bfd1fa8 ci: update Rust to 1.92.0 2025-12-11 21:23:53 +00:00
link2xt
9e2a4325e9 chore: apply Rust 1.92.0 clippy suggestions 2025-12-11 21:23:53 +00:00
link2xt
4509c1bd06 chore: prepare 2.34.0 release 2025-12-11 16:28:55 +00:00
Hocuri
3133d89dcc fix: Let securejoin succeed even if the chat was deleted in the meantime (#7594)
Fix https://github.com/chatmail/core/issues/7478 by creating the 1:1
chat in `handle_auth_required` if it doesn't exist anymore.
2025-12-11 17:20:41 +01:00
link2xt
99775458c4 test: test setting up second device between core versions 2025-12-10 22:51:17 +00:00
Hocuri
e432960246 feat: Better logging for failing securejoin messages (#7593)
This will make bugs like https://github.com/chatmail/core/issues/7478
easier to debug (even though we may be able to fix #7478 without waiting
for more logs)
2025-12-10 22:08:37 +00:00
link2xt
58cd133b5c fix: synchronize primary transport immediately after changing it 2025-12-09 21:43:26 +00:00
B. Petersen
3d234e7fc7 feat: double ringing time to 120 seconds
the ringing of 60 seconds is indeed a bit short,
esp. when messages are already delayed.
but also if everythings is fast and working, 60 seconds are short.

the 60 seconds also come from the first implementation,
where we did not had a "reject" message from callee to caller,
so that caller should not wait unnecessarily.

this has changed,
however, the other constraints are still valid -
phone may get offline, and we should not ring for stale calls.

sure, that can be fixed also differently,
but for the current implementation,
the 120 seconds seem to be a good compromise.
2025-12-09 15:20:05 +01:00
Simon Laux
595258ae05 fix add multi-transport information to Context.get_info (#7583)
This adds information of all used transports to `Context.get_info` in
the key `used_transport_settings`.

The format is the same as in `used_account_settings`. The new property
also contains the primary account, so we could remove
`used_account_settings` now, though there is also
`entered_account_settings` in which it stays in relation.

- there is an alternative pr at
https://github.com/chatmail/core/pull/7584, which gives each transport
it's own key, which improves readability.

closes #7581
2025-12-09 13:44:55 +00:00
Simon Laux
06b2a890da fix: multi-transport: all transports were shown as "inbox" in connectivity view, now they are shown by their hostname (#7582)
closes #7580
2025-12-09 13:42:24 +00:00
Simon Laux
95ed31391d fix: use logging macros instead of emitting event directly, so that it is also logged by tracing (#7459)
The events are needed when you are not using chatmail core from rust, if
you use chatmail core from your rust bot or from tauri, then you likely
already use the rust logging/tracing ecosystem. So it makes sense to use
it instead of listening to the events and logging them yourself.

This pr fixes a few cases where the event was direclty emitted instead
of using the macro and thus was not also automatically logged via
tracing.
2025-12-09 12:17:31 +00:00
iequidoo
98944efdb8 api: Forwarding messages to another profile (#7491)
Add `chat::forward_msgs_2ctx()` which takes another context as a parameter and forwards messages to
it and its jsonrpc wrapper `CommandApi::forward_messages_to_account()`.
2025-12-09 03:54:54 -03:00
iequidoo
3f27be9bcb refactor: Add params when forwarding message instead of removing unneeded ones
We periodically forget to remove new params from forwarded messages as this can't be catched by
existing tests, some examples:
bfc08abe88
a1837aeb8c
56b2361f01

This may leak confidential data. Instead, it's better to explicitly list params that we want to
forward, then if we forget to forward some param, a test on forwarding messages carrying the new
functionality will break, or the bug will be reported earlier, it's easier to notice that some info
is missing than some extra info is leaked.
2025-12-09 03:54:54 -03:00
link2xt
5902fe2cbe refactor: remove EncryptHelper.prefer_encrypt
It always had the same value of EncryptPreference::Mutual
2025-12-07 15:33:36 +00:00
link2xt
73e0f81e83 test: port test_synchronize_member_list_on_group_rejoin to JSON-RPC 2025-12-07 14:21:48 +00:00
link2xt
cbe842735e api(rpc-client): add Chat.num_contacts() 2025-12-07 14:21:48 +00:00
link2xt
72bc9f0ae4 api(rpc-client): accept Account for Chat.{add,remove}_contact() 2025-12-07 14:21:48 +00:00
link2xt
0defa117a0 refactor: use u16 instead of usize to represent progress bar 2025-12-07 13:23:31 +00:00
link2xt
3821cfab0c fix: use u64 to count the number of bytes sent/received over the network
It is possible to send more than 4 GiB into network stream
or receive more than 4 GiB from it, in which case
this counter may overflow.
2025-12-07 13:23:31 +00:00
link2xt
09f159991e fix: use u64 to represent the number of bytes in backup files 2025-12-07 13:23:31 +00:00
link2xt
014d2ace76 fix: use u64 instead of usize to calculate storage usage
On 32-bit systems such as old Android phones usize is 32-bit as well
and cannot store sizes that exceed 4 GiB.
2025-12-06 13:01:46 +00:00
link2xt
646728372b chore: add RUSTSEC-2025-0134 exception to deny.toml 2025-12-06 12:56:16 +00:00
link2xt
7c30aef2ed chore(release): prepare for 2.33.0 2025-12-05 21:35:21 +00:00
link2xt
c38d02728e fix: recognize all transport addresses as own addresses
Fix get_secondary_addrs() which was using
`secondary_addrs` config that is not updated anymore.
Instead of using `secondary_addrs` config,
use the `transports` table which contains all the addresses.
2025-12-05 21:15:30 +00:00
iequidoo
dea1b414db feat: Case-insensitive search for non-ASCII chat and contact names (#7477)
This makes `Contact::get_all()` and `Chatlist::try_load()` case-insensitive for non-ASCII chat and
contact names as well. The same approach as in f6f4ccc6ea "feat:
Case-insensitive search for non-ASCII messages (#5052)" is used: `chats.name_normalized` and
`contacts.name_normalized` colums are added which store lowercased/normalized names (for a contact,
if the name is unset, it's a normalized authname). If a normalized name is the same as the
chat/contact name, it's not stored to reduce the db size. A db migration is added for 10000 random
chats and the same number of the most recently seen contacts, for users it will probably migrate all
chats/contacts and for bots which may have more data it's not important.
2025-12-05 05:11:29 -03:00
link2xt
aa5ee19340 chore(release): prepare for 2.32.0 2025-12-04 21:00:59 +00:00
iequidoo
9271ecd208 feat: lookup_or_create_adhoc_group(): Add context to SQL errors (#7554) 2025-12-04 17:17:04 -03:00
link2xt
952f6735a2 chore(release): prepare for 2.31.0 2025-12-04 19:28:31 +00:00
link2xt
a50aa3b6e9 ci: update npm before publishing packages
Newer npm is apparently needed for "trusted publishing".
2025-12-04 19:26:25 +00:00
link2xt
23d95df66a feat: use v2 SEIPD when sending messages to self 2025-12-04 18:25:30 +00:00
link2xt
6db2cf6144 chore(release): prepare for 2.30.0 2025-12-04 17:01:28 +00:00
link2xt
47c1e54219 ci: use "trusted publishing" for NPM packages
See the documentation at <https://docs.npmjs.com/trusted-publishers>.
I have removed the token that was used since <https://github.com/chatmail/core/pull/5575>,
created two new GitHub deployment environments and configured
trusted publishing for two packages (see the environment URLs) on https://www.npmjs.com/
2025-12-04 15:40:28 +00:00
d2weber
b41c309e21 fix: shutdown shortly after call 2025-12-04 15:37:33 +00:00
link2xt
f7ae2abe52 feat: synchronize transports via sync messages 2025-12-04 14:40:36 +00:00
link2xt
3a7f82c66e api: add TransportsModified event 2025-12-04 14:40:36 +00:00
holger krekel
d75a78d446 feat: introduce cross-core testing along with improvements to test frameworking 2025-12-04 14:29:16 +00:00
iequidoo
676132457f feat: Disable SNI for STARTTLS (#7499)
Many clients don't send it currently, so it is unlikely that servers depend on it:
https://mastodon.social/@cks/114690055923939576.

For "implicit TLS", do not turn it off yet, it will serve as a fallback in case of rare server that
needs it. If the server only supports STARTTLS and requires SNI then it is really weird, likely
should not happen.
2025-12-04 05:11:50 -03:00
Simon Laux
8bce137e06 chore: remove two outdated todo comments (#7550)
remove two small outdated to do comments that are not relevant anymore
(already done or solved differently)
2025-12-02 19:08:24 +00:00
dependabot[bot]
f359a9c451 chore(cargo): bump sdp from 0.8.0 to 0.10.0
Bumps [sdp](https://github.com/webrtc-rs/webrtc) from 0.8.0 to 0.10.0.
- [Release notes](https://github.com/webrtc-rs/webrtc/releases)
- [Commits](https://github.com/webrtc-rs/webrtc/compare/v0.8.0...v0.10.0)

---
updated-dependencies:
- dependency-name: sdp
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 13:51:14 -03:00
dependabot[bot]
0d97a5b511 chore(deps): bump astral-sh/setup-uv from 7.1.3 to 7.1.4
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.1.3 to 7.1.4.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](5a7eac68fb...1e862dfacb)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 10:51:20 +00:00
dependabot[bot]
7ccc021aea chore(cargo): bump syn from 2.0.110 to 2.0.111
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.110 to 2.0.111.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.110...2.0.111)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 10:45:01 +00:00
dependabot[bot]
08e9cdc487 chore(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-02 10:40:16 +00:00
link2xt
12cee23924 chore(release): prepare for 2.29.0 2025-12-01 02:07:21 +00:00
link2xt
5fb118e5a3 refactor: make signing key non-optional for pk_encrypt 2025-12-01 01:16:01 +00:00
link2xt
1ec3f45dc1 refactor: use SYMMETRIC_KEY_ALGORITHM constant in symm_encrypt_message() 2025-12-01 01:16:01 +00:00
link2xt
e4e19b57b3 ci: build Python wheels in separate jobs 2025-11-30 02:59:17 +00:00
link2xt
2efb128fec ci: do not build fake RPC server source packages
The source packages were needed for Android
to repack compatible Linux wheels,
but now Android is officially supported.
2025-11-30 02:59:17 +00:00
link2xt
4a5d5bdeb1 ci: do not install Python manually to bulid RPC server wheels
Python preinstalled in runners should be up to date by now.
2025-11-30 02:59:17 +00:00
link2xt
cde4a61be7 ci: fix a typo in deltachat-rpc-server publishing workflow 2025-11-30 02:59:17 +00:00
dependabot[bot]
ca2b4d7a6f chore(cargo): bump tokio from 1.45.1 to 1.48.0
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.45.1 to 1.48.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.45.1...tokio-1.48.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...
2025-11-29 21:20:08 -03:00
iequidoo
ef61c0c408 test: test_remove_member_bcc: Test unencrypted group as it was initially 2025-11-29 17:44:52 -03:00
link2xt
dc5f939ac6 feat!: increase backup version from 3 to 4
Migration 140 merged in version 2.28.0
introduced `NOT NULL` transport_id
columns, so old versions of core not aware of it
fail e.g. when they expect conflict on `(folder)`
column rather than `(transport_id, folder)`.
2025-11-29 17:24:08 +00:00
iequidoo
2c0092738f feat: Don't update self-{avatar,status} from received messages (#7002)
The normal way of synchronizing self-avatar and -status nowadays is sync messages.
2025-11-29 02:19:09 -03:00
dependabot[bot]
b1e6cf2052 chore(cargo): bump rand from 0.9.0 to 0.9.2 (#7501) 2025-11-29 00:18:03 +00:00
link2xt
343dca87f7 fix: do not use deprecated ConfiguredProvider in get_configured_provider 2025-11-29 00:17:26 +00:00
link2xt
4d06f5a8ae fix: upload sync messages only with the primary transport
Currently there is a race between transports
to upload sync messages and delete them from `imap_send` table.
Sometimes mulitple transports upload the same message
and sometimes only some of them "win".
With this change only the primary transport
will upload the sync message.
2025-11-29 00:17:26 +00:00
link2xt
a4bec7dc70 refactor: remove update_icons and disable_server_delete migrations 2025-11-29 00:17:26 +00:00
link2xt
1f32c5ab40 refactor: use ConfiguredProvider config directly when loading legacy settings 2025-11-29 00:17:26 +00:00
link2xt
43e8d5cc6c ci: unpin mypy 2025-11-29 00:16:58 +00:00
link2xt
7e4547582e fix: do not configure folders during transport configuration
We do not have transport ID assigned until configuration finishes,
so we cannot save UID validitiy for folders and write
into any tables that have transport_id column yet.
2025-11-29 00:05:29 +00:00
link2xt
721b9cebef build: pin mypy to 1.18.2
mypy depends on librt since 1.19.0
and it fails to build with PyPy 3.10.
2025-11-28 22:14:38 +00:00
link2xt
0d50d8703f build: use SPDX license expression in Python package metadata
License classifiers are replaced with `license`.
This is supported since `setuptools` v77.0.0:
<https://setuptools.pypa.io/en/stable/history.html#v77-0-0>

Without this change we get
`SetuptoolsDeprecationWarning: License classifiers are deprecated.`
with a reference to
<https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license>
2025-11-28 22:14:38 +00:00
link2xt
9aba299c75 build: increase minimum supported Python version to 3.10
Python 3.9 is not supported since 2025-10-31:
https://devguide.python.org/versions/

mypy just dropped support for Python 3.9:
<1a6ff59049>
2025-11-28 22:14:38 +00:00
iequidoo
2854f87a9d fix: CREATE INDEX imap_only_rfc724_mid ON imap(rfc724_mid) (#7490)
This effectively readds the old `imap_rfc724_mid` built only on `rfc724_mid`, otherwise
`sql::prune_tombstones()` which is called from `housekeeping()` becomes very slow because of no
suitable index.
2025-11-27 22:23:03 -03:00
iequidoo
145a5813e8 feat: Don't send Chat-Group-Avatar header in unencrypted groups
`chat::set_chat_profile_image()` already checks that the group has grpid, still it makes sense to
check that a message is encrypted when sending, in case if the chat has a profile image in the db
for some reason.
2025-11-27 22:02:11 -03:00
iequidoo
4cb129a67e fix: Don't send self-avatar in unencrypted messages (#7136)
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.
2025-11-27 22:02:11 -03:00
link2xt
7bf7ec3d32 api(deltachat-rpc-client): add Message.exists() 2025-11-28 00:30:36 +00:00
link2xt
8a7498a9a8 fix: handle the case when account does not exist in get_existing_msg_ids()
If account is removed, this means the messages are removed as well.
We do not reuse account IDs, so the account will not reappear.
2025-11-28 00:30:36 +00:00
dependabot[bot]
c41a69ea1e chore(cargo): bump image from 0.25.8 to 0.25.9
Bumps [image](https://github.com/image-rs/image) from 0.25.8 to 0.25.9.
- [Changelog](https://github.com/image-rs/image/blob/main/CHANGES.md)
- [Commits](https://github.com/image-rs/image/compare/v0.25.8...v0.25.9)

---
updated-dependencies:
- dependency-name: image
  dependency-version: 0.25.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Co-authored-by: iequidoo <dgreshilov@gmail.com>
2025-11-27 18:39:17 +01:00
link2xt
38a547dfda build: make scripts for remote testing usable
This updates `scripts/remote_tests_{rust,python}.sh`.
The scripts were previously used to run tests
from CI on remote faster machine,
but they are still usable to run tests remotely
e.g. from a laptop that is on battery.
2025-11-26 17:12:25 +00:00
link2xt
7c998af973 ci: set timeout-minutes for all jobs in ci.yaml workflow
Default is 360 minutes, that is 6 hours.
If the job is running for more than 1 hour,
it is surely stuck, no need to keep running it.
2025-11-26 17:06:20 +00:00
link2xt
6b6ec2a4b7 fix: use the same webxdc ratelimit for all email servers
This removes another distinction between chatmail and non-chatmail
and fixes flaky tests/test_webxdc.py::test_webxdc

Closes <https://github.com/chatmail/core/issues/7522>
2025-11-26 00:47:43 +00:00
dependabot[bot]
b1fa1055d7 chore(cargo): bump bytes from 1.10.1 to 1.11.0
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.0.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.0)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 16:05:26 -03:00
dependabot[bot]
15ce05b0c7 chore(cargo): bump libc from 0.2.176 to 0.2.177
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.176 to 0.2.177.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.177/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.176...0.2.177)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.177
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 14:56:34 +00:00
dependabot[bot]
8112183429 chore(cargo): bump quote from 1.0.41 to 1.0.42
Bumps [quote](https://github.com/dtolnay/quote) from 1.0.41 to 1.0.42.
- [Release notes](https://github.com/dtolnay/quote/releases)
- [Commits](https://github.com/dtolnay/quote/compare/1.0.41...1.0.42)

---
updated-dependencies:
- dependency-name: quote
  dependency-version: 1.0.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 14:56:11 +00:00
holger krekel
b9ae74fab2 feat: deltachat_rpc_client.Rpc accepts rpc_server_path for using a particular deltachat-rpc-server (#7493)
also simplifies and make more readable popen-call to rpc-server 

addresses #7428
2025-11-25 11:02:42 +01:00
dependabot[bot]
fdff90eba4 chore(cargo): bump sanitize-filename from 0.5.0 to 0.6.0
Bumps [sanitize-filename](https://github.com/kardeiz/sanitize-filename) from 0.5.0 to 0.6.0.
- [Commits](https://github.com/kardeiz/sanitize-filename/commits)

---
updated-dependencies:
- dependency-name: sanitize-filename
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:30:50 +00:00
dependabot[bot]
a4a54d3648 chore(cargo): bump nu-ansi-term from 0.50.1 to 0.50.3
Bumps [nu-ansi-term](https://github.com/nushell/nu-ansi-term) from 0.50.1 to 0.50.3.
- [Release notes](https://github.com/nushell/nu-ansi-term/releases)
- [Changelog](https://github.com/nushell/nu-ansi-term/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nushell/nu-ansi-term/compare/v0.50.1...v0.50.3)

---
updated-dependencies:
- dependency-name: nu-ansi-term
  dependency-version: 0.50.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:12:49 +00:00
dependabot[bot]
98fb760f49 chore(cargo): bump rustls-pki-types from 1.12.0 to 1.13.0
Bumps [rustls-pki-types](https://github.com/rustls/pki-types) from 1.12.0 to 1.13.0.
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.12.0...v/1.13.0)

---
updated-dependencies:
- dependency-name: rustls-pki-types
  dependency-version: 1.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:07:20 +00:00
dependabot[bot]
f553c094eb chore(cargo): bump quick-xml from 0.38.3 to 0.38.4
Bumps [quick-xml](https://github.com/tafia/quick-xml) from 0.38.3 to 0.38.4.
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.38.3...v0.38.4)

---
updated-dependencies:
- dependency-name: quick-xml
  dependency-version: 0.38.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:06:55 +00:00
dependabot[bot]
bbb4bed996 chore(cargo): bump syn from 2.0.106 to 2.0.110
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.106 to 2.0.110.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.106...2.0.110)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:06:38 +00:00
dependabot[bot]
25088f2dcb chore(cargo): bump parking_lot from 0.12.4 to 0.12.5
Bumps [parking_lot](https://github.com/Amanieu/parking_lot) from 0.12.4 to 0.12.5.
- [Release notes](https://github.com/Amanieu/parking_lot/releases)
- [Changelog](https://github.com/Amanieu/parking_lot/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Amanieu/parking_lot/compare/parking_lot-v0.12.4...parking_lot-v0.12.5)

---
updated-dependencies:
- dependency-name: parking_lot
  dependency-version: 0.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:05:37 +00:00
dependabot[bot]
552e9f4052 chore(cargo): bump proptest from 1.8.0 to 1.9.0
Bumps [proptest](https://github.com/proptest-rs/proptest) from 1.8.0 to 1.9.0.
- [Release notes](https://github.com/proptest-rs/proptest/releases)
- [Changelog](https://github.com/proptest-rs/proptest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/proptest-rs/proptest/compare/v1.8.0...v1.9.0)

---
updated-dependencies:
- dependency-name: proptest
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:04:44 +00:00
dependabot[bot]
b3616a013f chore(cargo): bump toml from 0.9.7 to 0.9.8
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.7 to 0.9.8.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.7...toml-v0.9.8)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 03:04:29 +00:00
dependabot[bot]
c378b1218d chore(cargo): bump tokio-util from 0.7.16 to 0.7.17
Bumps [tokio-util](https://github.com/tokio-rs/tokio) from 0.7.16 to 0.7.17.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-util-0.7.16...tokio-util-0.7.17)

---
updated-dependencies:
- dependency-name: tokio-util
  dependency-version: 0.7.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 02:24:48 +00:00
dependabot[bot]
21f6e7c676 chore(cargo): bump rusqlite from 0.36.0 to 0.37.0
Bumps [rusqlite](https://github.com/rusqlite/rusqlite) from 0.36.0 to 0.37.0.
- [Release notes](https://github.com/rusqlite/rusqlite/releases)
- [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md)
- [Commits](https://github.com/rusqlite/rusqlite/compare/v0.36.0...v0.37.0)

---
updated-dependencies:
- dependency-name: rusqlite
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 02:08:51 +00:00
dependabot[bot]
ac543ad251 chore(cargo): bump hyper-util from 0.1.17 to 0.1.18
Bumps [hyper-util](https://github.com/hyperium/hyper-util) from 0.1.17 to 0.1.18.
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.17...v0.1.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 02:07:16 +00:00
dependabot[bot]
183898b137 chore(cargo): bump human-panic from 2.0.3 to 2.0.4
Bumps [human-panic](https://github.com/rust-cli/human-panic) from 2.0.3 to 2.0.4.
- [Changelog](https://github.com/rust-cli/human-panic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/human-panic/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: human-panic
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 02:06:07 +00:00
dependabot[bot]
56204ae701 chore(cargo): bump hyper from 1.7.0 to 1.8.1
Bumps [hyper](https://github.com/hyperium/hyper) from 1.7.0 to 1.8.1.
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.7.0...v1.8.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 02:04:59 +00:00
dependabot[bot]
7906405400 chore(deps): bump cachix/install-nix-action from 31.8.1 to 31.8.4
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.1 to 31.8.4.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md)
- [Commits](fd24c48048...0b0e072294)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 23:25:32 +00:00
Hocuri
531e0bc914 refactor!: Remove some unneeded stock strings (#7496)
There are quite some unneeded stock strings; this PR removes some of
them. None of these stock strings were actually set by the UI, or even
have translations in Transifex.
- We don't have AEAP anymore.
- The "I added/removed member" and "I left the group" strings are
anyways not meant to be shown to the user. Also, starting to translate
them now would leak the device language.

BREAKING CHANGE: This can theoretically be a breaking change because a
UI could reference one of the removed stock strings, so I marked it as
breaking just in case.
2025-11-24 19:55:12 +01:00
link2xt
3637fe67a7 feat: Hide To header in encrypted messages 2025-11-24 02:33:56 -03:00
holger krekel
8eef79f95d refactor: strike events in rpc-client request handling, get result from queue 2025-11-23 21:23:53 +01:00
link2xt
6077499f07 chore(release): prepare for 2.28.0 2025-11-23 17:08:42 +00:00
Simon Laux
94d2d8cfd7 feat: add api to get information about diskspace usage of database. (jsonrpc method: get_storage_usage_report_string) (#7486)
new jsonrpc api: `get_storage_usage_report_string(accountId)`
new rust API: `get_storage_usage(&context)`
2025-11-23 15:18:00 +00:00
iequidoo
ba3cad6ad6 docs: Mark db encryption support as deprecated (#7403)
- Db encryption does nothing with blobs, so fs/disk encryption is recommended.
- Isolation from other apps is needed anyway.
- Experimental database encryption was removed on iOS and Android.
- Delta Touch is using CFFI API with a manually entered password because Ubuntu Touch does not offer
  filesystem or disk encryption, but we don't want new users of these APIs, such as bot developers.
2025-11-22 18:36:40 -03:00
link2xt
c9c362d5ff api: get_existing_msg_ids()
This API allows to check if the message with
given ID exists and distinguish between
message not existing and database error.
It might also be faster than
checking messages one by one
if multiple messages need to be checked
because of using a single SQL transaction.
2025-11-22 18:19:44 +00:00
iequidoo
6514b4ca7f fix: Look up or create ad-hoc group if there are duplicate addresses in "To"
Fix `test_unencrypted_doesnt_goto_self_chat` as well, it was only testing the first message because
of using the same Message-ID for all messages.
2025-11-22 02:48:27 -03:00
link2xt
e7e31d7914 ci: do not use --encoding option for rst-lint
It was removed in rst-lint 2.0:
7b43036b4d
2025-11-22 05:26:03 +00:00
B. Petersen
51d6855e0d fix: add missing stock strings 2025-11-21 14:42:50 +01:00
Hocuri
2f90b55309 feat: Stock string for joining a channel (#7480)
Add a stock string `%1$s invited you to join this channel.\n\nWaiting
for the device of %2$s to reply…`, which is shown when a user starts to
join a channel.

I did _not_ add an equivalent to `%1$s replied, waiting for being added
to the group…`, which is shown when vg-auth-required was received. I
don't think that this would add any information that's interesting to
the user, other than 'something is happening, hang on'. And the more
text on the screen, the less likely that anyone reads it. But if others
think differently, we can also add it.

With this PR, joining a channel looks like this:

```
Msg#2003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#2004: info (Contact#Contact#Info): Alice invited you to join this channel.

Waiting for the device of Alice to reply… [NOTICED][INFO]
Msg#2007🔒:  (Contact#Contact#2001): You joined the channel. [FRESH][INFO]
```
2025-11-20 21:13:04 +00:00
link2xt
be3e202470 feat: allow adding second transport 2025-11-20 15:51:19 +00:00
link2xt
57aadfbbf6 chore: update preloaded DNS cache 2025-11-19 19:55:53 +00:00
link2xt
849cde9757 refactor: remove some easy to remove unwrap() calls 2025-11-19 17:38:58 +00:00
link2xt
b4cd99fc56 docs: replace some references to Delta Chat with chatmail 2025-11-19 04:11:12 +00:00
iequidoo
9305a0676c fix: Assign outgoing self-sent unencrypted messages to ad-hoc groups with only SELF (#7409)
Before, outgoing self-sent unencrypted messages were assigned to the self-chat. Now we assign them
to ad-hoc groups with only SELF instead of 1:1 chats with address contacts corresponding to our own
addresses because we don't want to create such address contacts; we still use SELF for `from_id` of
such messages. Not assigning such messages to the encrypted chat should be safe enough and such
messages can actually be sent by the user from another MUA.
2025-11-18 20:34:56 -03:00
B. Petersen
39c9ba19ef docs: add missing RFC 9788, link 'Header Protection for Cryptographically Protected Email' as other RFC 2025-11-18 23:16:10 +01:00
link2xt
af574279fd docs: remove unsupported RFC 3503 ($MDNSent flag) from the list of standards 2025-11-18 21:44:41 +00:00
Hocuri
713c929e03 refactor: Rename add_encrypted_msg -> add_e2ee_notice 2025-11-18 18:58:26 +01:00
Hocuri
c83c131a37 feat: Rephrase "Establishing end-to-end encryption" -> "Establishing connection" 2025-11-18 18:58:26 +01:00
Hocuri
0d0602a4a5 fix: Sort system messages to the bottom of the chat
Fix #7435

For most messages, `calc_sort_timestamp()` makes sure that they are at the correct place; esp. that they are not above system messages or other noticed/seen messages.

Most callers of `add_info_msg()`, however, didn't call `calc_sort_timestamp()`, and just used `time()` or `smeared_time()` to get the sort timestamp. Because of this, system messages could sometimes wrongly be sorted above other messages.

This PR fixes this by making the sort timestamp optional in `add_info_msg*()`. If the sort timestamp isn't passed, then the message is sorted to the bottom of the chat. `sent_rcvd_timestamp` is not optional anymore, because we need _some_ timestamp that can be shown to the user (most callers just pass `time()` there).
2025-11-18 18:58:26 +01:00
link2xt
abfb556377 fix: set SQLite busy timeout to 1 minute on iOS
Closes <https://github.com/chatmail/core/issues/7464>
2025-11-18 17:07:27 +00:00
link2xt
72788daca0 refactor: use HashMap::extract_if() stabilized in Rust 1.88.0 2025-11-18 13:16:44 +00:00
iequidoo
16bd87c78f test: Contact shalln't be verified by another having unknown verifier
It must be verified by "unknown verifier" instead. But if the verifier has known verifier in turn,
it must reverify contacts having unknown verifier. Add a check for this also.
2025-11-18 05:42:46 -03:00
iequidoo
d44e2420bc fix: ContactId::set_name_ex(): Emit ContactsChanged when transaction is completed
This fixes flaky JSON-RPC's `test_rename_synchronization()`.
2025-11-18 02:17:31 -03:00
dependabot[bot]
88d213fcdb chore(deps): bump astral-sh/setup-uv from 7.1.2 to 7.1.3
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.1.2 to 7.1.3.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](85856786d1...5a7eac68fb)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-18 02:12:53 +00:00
link2xt
fb14acb0fb fix: limit the range of Date to up to 6 days in the past
Previous value (1000000 seconds) was slightly more than 11.5 days.
2025-11-17 23:17:55 +00:00
link2xt
a5c470fbae build(nix): update fenix and use it for all Rust builds
`fenix` input provides updated Rust packages.
Updating it is needed since current version is 1.86.0
and MSRV has been increased to 1.88.0.
2025-11-17 15:23:58 +00:00
link2xt
6bdba33d32 build: update rPGP from 0.17.0 to 0.18.0 2025-11-16 15:18:55 +00:00
link2xt
c6ace749e3 build: increase MSRV to 1.88.0
It is required by rPGP 0.18.0.

All the changes in `.rs` files are made automatically with `clippy --fix`.
2025-11-16 14:48:50 +00:00
link2xt
22ebd6436f feat: default bcc_self to 0 for new accounts 2025-11-16 10:00:00 +00:00
link2xt
cdfe436124 chore(release): prepare for 2.27.0 2025-11-16 06:34:11 +00:00
link2xt
e8823fcf35 test: test background_fetch() and stop_background_fetch() 2025-11-16 05:59:20 +00:00
link2xt
0136cfaf6a test: add pytest fixture for account manager 2025-11-16 05:59:20 +00:00
link2xt
07069c348b api(deltachat-rpc-client): add APIs for background fetch 2025-11-16 05:59:20 +00:00
Hocuri
26f6b85ff9 feat!: Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. (#7439)
Add the ability to withdraw broadcast invite codes

After merging:
- [x] Create issues in iOS, Desktop and UT repositories
2025-11-15 19:27:04 +01:00
Hocuri
10b6dd1f11 test(rpc-client): test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist (#7442)
Fix flaky test by calling `get_broadcast()` after the message events
were received.

Alternative to https://github.com/chatmail/core/pull/7437
2025-11-15 18:49:16 +01:00
B. Petersen
cae642b024 fix: send webm as file, it is not supported by all UI 2025-11-15 14:55:40 +01:00
B. Petersen
54a2e94525 fix: deprecate deletion timer string for '1 Minute'
the minimum timestamp in UI is 5 minutes
and the old string is about to be removed from translations.
the 'seconds' fallback is good enough, however
2025-11-15 14:41:54 +01:00
link2xt
9d4ad00fc0 build(nix): exclude CONTRIBUTING.md from the source files 2025-11-15 10:56:08 +00:00
Nico de Haen
102b72aadd fix: escape connectivity html 2025-11-14 22:50:15 +00:00
link2xt
1c4d2dd78e api: add APIs to stop background fetch
New APIs are JSON-RPC method stop_background_fetch(),
Rust method Accounts.stop_background_fetch()
and C method dc_accounts_stop_background_fetch().

These APIs allow to cancel background fetch early
even before the initially set timeout,
for example on Android when the system calls
`Service.onTimeout()` for a `dataSync` foreground service.
2025-11-14 22:48:19 +00:00
link2xt
cd50c263e8 api!(jsonrpc): rename accounts_background_fetch() into background_fetch()
There is no JSON-RPC method to run background_fetch() for a single account,
so no need to have a qualifier saying that it is for all accounts.
2025-11-14 22:48:19 +00:00
iequidoo
1dbcd7f1f4 test: HP-Outer headers are added to messages with standard Header Protection (#7130) 2025-11-14 19:45:32 -03:00
iequidoo
c6894f56b2 feat: Add Config::StdHeaderProtectionComposing (enables composing as defined in RFC 9788) (#7130)
And enable it by default as the standard Header Protection is backward-compatible.

Also this tests extra IMF header removal when a message has standard Header Protection since now we
can send such messages.
2025-11-14 19:45:32 -03:00
iequidoo
e2ae6ae013 feat: mimeparser: Omit Legacy Display Elements (#7130)
Omit Legacy Display Elements from "text/plain" and "text/html" (implement 4.5.3.{2,3} of
https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for Cryptographically Protected Email").
2025-11-14 19:45:32 -03:00
iequidoo
966ea28f83 feat: Ignore unprotected headers if Content-Type has "hp" parameter (#7130)
This is a part of implementation of https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for
Cryptographically Protected Email".
2025-11-14 19:45:32 -03:00
link2xt
6611a9fa02 fix: always set bcc_self on backup import/export
Regardless of whether chatmail relay is used or not,
bcc_self should be enabled when second device is added.
It should also be enabled again even if the user
has turned it off manually.
2025-11-14 20:00:34 +00:00
Simon Laux
dc4ea1865a fix: set get_max_smtp_rcpt_to for chatmail to the actual limit of 1000 instead of unlimited. (#7432)
adb brought this up in an internal discussion.
With the recent introduction of channels it becomes easier to hit the
limit
and it becomes impossible to send messages to a channel with more than
1000 members, this pr fixes that.

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-11-13 18:36:35 +00:00
link2xt
4b1dff601d refactor: use wait_for_incoming_msg() in more tests 2025-11-13 00:25:16 +00:00
link2xt
a66808e25a api(rpc-client): add Account.wait_for_msg() 2025-11-13 00:25:16 +00:00
link2xt
7b54954401 test: port folder-related CFFI tests to JSON-RPC
Created new test_folders.py

Moved existing JSON-RPC tests:
- test_reactions_for_a_reordering_move
- test_delete_deltachat_folder

Ported tests:
- test_move_works_on_self_sent
- test_moved_markseen
- test_markseen_message_and_mdn
- test_mvbox_thread_and_trash (renamed to test_mvbox_and_trash)
- test_scan_folders
- test_move_works
- test_move_avoids_loop
- test_immediate_autodelete
- test_trash_multiple_messages

The change also contains fixes for direct_imap fixture
needed to use IMAP IDLE in JSON-RPC tests.
2025-11-12 08:07:40 +00:00
link2xt
d39ed9d0f1 test: fix flaky test_send_receive_locations 2025-11-12 05:50:00 +00:00
iequidoo
c499dabbe1 feat: Add Contact::get_or_gen_color. Use it in CFFI and JSON-RPC to avoid gray self-color (#7374)
`Contact::get_color()` returns gray if own keypair doesn't exist yet and we don't want any UIs
displaying it. Keypair generation can't be done in `get_color()` or `get_by_id_optional()` to avoid
breaking Core tests on key import. Also this makes the API clearer, pure getters shouldn't modify
any visible state.
2025-11-12 00:30:42 -03:00
Hocuri
e70307af1f feat: Tweak initial info-message for unencrypted chats (#7427)
Fix https://github.com/chatmail/core/issues/7404
2025-11-11 19:28:28 +01:00
link2xt
69a3a31554 chore(release): prepare for 2.26.0 2025-11-11 17:30:19 +00:00
link2xt
1cb0a25e16 fix: do not ignore I/O errors in BlobObject::store_from_base64 2025-11-08 20:06:45 +00:00
iequidoo
fdea6c8af3 feat: Error toast for "Not creating securejoin QR for old broadcast" 2025-11-08 16:23:15 -03:00
151 changed files with 6526 additions and 3896 deletions

View File

@@ -20,17 +20,18 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.91.0
RUST_VERSION: 1.92.0
# Minimum Supported Rust Version
MSRV: 1.85.0
MSRV: 1.88.0
jobs:
lint_rust:
name: Lint Rust
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -39,7 +40,7 @@ jobs:
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Run rustfmt
run: cargo fmt --all -- --check
- name: Run clippy
@@ -52,12 +53,13 @@ jobs:
cargo_deny:
name: cargo deny
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@v2
- uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918
with:
arguments: --all-features --workspace
command: check
@@ -66,8 +68,9 @@ jobs:
provider_database:
name: Check provider database
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -79,15 +82,16 @@ jobs:
docs:
name: Rust doc comments
runs-on: ubuntu-latest
timeout-minutes: 60
env:
RUSTDOCFLAGS: -Dwarnings
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Rustdoc
run: cargo doc --document-private-items --no-deps
@@ -107,6 +111,7 @@ jobs:
- os: ubuntu-latest
rust: minimum
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- run:
echo "RUSTUP_TOOLCHAIN=$MSRV" >> $GITHUB_ENV
@@ -117,7 +122,7 @@ jobs:
shell: bash
if: matrix.rust == 'latest'
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -129,10 +134,10 @@ jobs:
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Install nextest
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@69e777b377e4ec209ddad9426ae3e0c1008b0ef3
with:
tool: nextest
@@ -155,14 +160,15 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build C library
run: cargo build -p deltachat_ffi
@@ -180,14 +186,15 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build deltachat-rpc-server
run: cargo build -p deltachat-rpc-server
@@ -202,8 +209,9 @@ jobs:
python_lint:
name: Python lint
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -219,6 +227,38 @@ jobs:
working-directory: deltachat-rpc-client
run: tox -e lint
# mypy does not work with PyPy since mypy 1.19
# as it introduced native `librt` dependency
# that uses CPython internals.
# We only run mypy with CPython because of this.
cffi_python_mypy:
name: CFFI Python mypy
needs: ["c_library", "python_lint"]
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Download libdeltachat.a
uses: actions/download-artifact@v6
with:
name: ubuntu-latest-libdeltachat.a
path: target/debug
- name: Install tox
run: pip install tox
- name: Run mypy
env:
DCC_RS_TARGET: debug
DCC_RS_DEV: ${{ github.workspace }}
working-directory: python
run: tox -e mypy
cffi_python_tests:
name: CFFI Python tests
needs: ["c_library", "python_lint"]
@@ -238,15 +278,16 @@ jobs:
- os: macos-latest
python: pypy3.10
# Minimum Supported Python Version = 3.8
# Minimum Supported Python Version = 3.10
# This is the minimum version for which manylinux Python wheels are
# built. Test it with minimum supported Rust version.
- os: ubuntu-latest
python: 3.8
python: "3.10"
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -271,7 +312,7 @@ jobs:
DCC_RS_TARGET: debug
DCC_RS_DEV: ${{ github.workspace }}
working-directory: python
run: tox -e mypy,doc,py
run: tox -e doc,py
rpc_python_tests:
name: JSON-RPC Python tests
@@ -293,13 +334,14 @@ jobs:
- os: macos-latest
python: pypy3.10
# Minimum Supported Python Version = 3.8
# Minimum Supported Python Version = 3.10
- os: ubuntu-latest
python: 3.8
python: "3.10"
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false

View File

@@ -30,11 +30,11 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -46,6 +46,30 @@ jobs:
path: result/bin/deltachat-rpc-server
if-no-files-found: error
build_linux_wheel:
name: Linux wheel
strategy:
fail-fast: false
matrix:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
- name: Upload wheel
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
path: result/*.whl
if-no-files-found: error
build_windows:
name: Windows
strategy:
@@ -54,11 +78,11 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -70,6 +94,30 @@ jobs:
path: result/bin/deltachat-rpc-server.exe
if-no-files-found: error
build_windows_wheel:
name: Windows wheel
strategy:
fail-fast: false
matrix:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
- name: Upload wheel
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
path: result/*.whl
if-no-files-found: error
build_macos:
name: macOS
strategy:
@@ -79,7 +127,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -105,11 +153,11 @@ jobs:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -121,9 +169,33 @@ jobs:
path: result/bin/deltachat-rpc-server
if-no-files-found: error
build_android_wheel:
name: Android wheel
strategy:
fail-fast: false
matrix:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
- name: Upload wheel
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
path: result/*.whl
if-no-files-found: error
publish:
name: Build wheels and upload binaries to the release
needs: ["build_linux", "build_windows", "build_macos"]
needs: ["build_linux", "build_linux_wheel", "build_windows", "build_windows_wheel", "build_macos", "build_android", "build_android_wheel"]
environment:
name: pypi
url: https://pypi.org/p/deltachat-rpc-server
@@ -132,11 +204,11 @@ jobs:
contents: write
runs-on: "ubuntu-latest"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v6
@@ -144,42 +216,84 @@ jobs:
name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux aarch64 wheel
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-aarch64-linux-wheel
path: deltachat-rpc-server-aarch64-linux-wheel.d
- name: Download Linux armv7l binary
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv7l wheel
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv7l-linux-wheel
path: deltachat-rpc-server-armv7l-linux-wheel.d
- name: Download Linux armv6l binary
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux armv6l wheel
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv6l-linux-wheel
path: deltachat-rpc-server-armv6l-linux-wheel.d
- name: Download Linux i686 binary
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d
- name: Download Linux i686 wheel
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-i686-linux-wheel
path: deltachat-rpc-server-i686-linux-wheel.d
- name: Download Linux x86_64 binary
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d
- name: Download Linux x86_64 wheel
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-x86_64-linux-wheel
path: deltachat-rpc-server-x86_64-linux-wheel.d
- name: Download Win32 binary
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d
- name: Download Win32 wheel
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win32-wheel
path: deltachat-rpc-server-win32-wheel.d
- name: Download Win64 binary
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d
- name: Download Win64 wheel
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win64-wheel
path: deltachat-rpc-server-win64-wheel.d
- name: Download macOS binary for x86_64
uses: actions/download-artifact@v6
with:
@@ -198,12 +312,24 @@ jobs:
name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android wheel for arm64-v8a
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-arm64-v8a-android-wheel
path: deltachat-rpc-server-arm64-v8a-android-wheel.d
- name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d
- name: Download Android wheel for armeabi-v7a
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armeabi-v7a-android-wheel
path: deltachat-rpc-server-armeabi-v7a-android-wheel.d
- name: Create bin/ directory
run: |
mkdir -p bin
@@ -222,38 +348,21 @@ jobs:
- name: List binaries
run: ls -l bin/
# Python 3.11 is needed for tomllib used in scripts/wheel-rpc-server.py
- name: Install python 3.12
uses: actions/setup-python@v6
with:
python-version: 3.12
- name: Install wheel
run: pip install wheel
- name: Build deltachat-rpc-server Python wheels and source package
- name: Build deltachat-rpc-server Python wheels
run: |
mkdir -p dist
nix build .#deltachat-rpc-server-x86_64-linux-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-armv7l-linux-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-armv6l-linux-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-aarch64-linux-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-i686-linux-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-win64-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-win32-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-arm64-v8a-android-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-armeabi-v7a-android-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-source
cp result/*.tar.gz dist/
mv deltachat-rpc-server-aarch64-linux-wheel.d/*.whl dist/
mv deltachat-rpc-server-armv7l-linux-wheel.d/*.whl dist/
mv deltachat-rpc-server-armv6l-linux-wheel.d/*.whl dist/
mv deltachat-rpc-server-i686-linux-wheel.d/*.whl dist/
mv deltachat-rpc-server-x86_64-linux-wheel.d/*.whl dist/
mv deltachat-rpc-server-win64-wheel.d/*.whl dist/
mv deltachat-rpc-server-win32-wheel.d/*.whl dist/
mv deltachat-rpc-server-arm64-v8a-android-wheel.d/*.whl dist/
mv deltachat-rpc-server-armeabi-v7a-android-wheel.d/*.whl dist/
python3 scripts/wheel-rpc-server.py x86_64-darwin bin/deltachat-rpc-server-x86_64-macos
python3 scripts/wheel-rpc-server.py aarch64-darwin bin/deltachat-rpc-server-aarch64-macos
mv *.whl dist/
@@ -271,21 +380,24 @@ jobs:
--repo ${{ github.repository }} \
bin/* dist/*
- name: Publish deltachat-rpc-client to PyPI
- name: Publish deltachat-rpc-server to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
publish_npm_package:
name: Build & Publish npm prebuilds and deltachat-rpc-server
needs: ["build_linux", "build_windows", "build_macos"]
runs-on: "ubuntu-latest"
environment:
name: npm-stdio-rpc-server
url: https://www.npmjs.com/package/@deltachat/stdio-rpc-server
permissions:
id-token: write
# Needed to publish the binaries to the release.
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -406,11 +518,14 @@ jobs:
node-version: 20
registry-url: "https://registry.npmjs.org"
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
- name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server`
if: github.event_name == 'release'
working-directory: deltachat-rpc-server/npm-package
run: |
ls -lah platform_package
for platform in *.tgz; do npm publish --provenance "$platform" --access public; done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -10,11 +10,14 @@ jobs:
pack-module:
name: "Publish @deltachat/jsonrpc-client"
runs-on: ubuntu-latest
environment:
name: npm-jsonrpc-client
url: https://www.npmjs.com/package/@deltachat/jsonrpc-client
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -24,6 +27,11 @@ jobs:
node-version: 20
registry-url: "https://registry.npmjs.org"
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
- name: Install dependencies without running scripts
working-directory: deltachat-jsonrpc/typescript
run: npm install --ignore-scripts
@@ -37,5 +45,3 @@ jobs:
- name: Publish
working-directory: deltachat-jsonrpc/typescript
run: npm publish --provenance deltachat-jsonrpc-client-* --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -16,7 +16,7 @@ jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -25,7 +25,7 @@ jobs:
with:
node-version: 18.x
- name: Add Rust cache
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: npm install
working-directory: deltachat-jsonrpc/typescript
run: npm install

View File

@@ -21,11 +21,11 @@ jobs:
name: check flake formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- run: nix fmt flake.nix -- --check
build:
@@ -80,11 +80,11 @@ jobs:
#- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -101,9 +101,9 @@ jobs:
# because of <https://github.com/NixOS/nixpkgs/issues/413910>.
# - deltachat-rpc-server-aarch64-darwin
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- run: nix build .#${{ matrix.installable }}

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -47,4 +47,4 @@ jobs:
name: python-package-distributions
path: dist/
- name: Publish deltachat-rpc-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e

View File

@@ -14,11 +14,11 @@ jobs:
name: Build REPL example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -31,12 +31,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -50,12 +50,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat
@@ -72,7 +72,7 @@ jobs:
working-directory: ./deltachat-jsonrpc/typescript
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false

View File

@@ -14,12 +14,12 @@ jobs:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244
- name: Run zizmor
run: uvx zizmor --format sarif . > results.sarif

View File

@@ -1,5 +1,343 @@
# Changelog
## [2.35.0] - 2025-12-16
### API-Changes
- Add blob dir size to storage info ([#7605](https://github.com/chatmail/core/pull/7605)).
### Features / Changes
- Use `turn.delta.chat` as fallback TURN server ([#7382](https://github.com/chatmail/core/pull/7382)).
- Add ip addresses of known public chatmail relays from https://chatmail.at/relays to DNS cache ([#7607](https://github.com/chatmail/core/pull/7607)).
- Improve error messages on adding relays.
- Add transport addresses to IMAP URLs in message info.
- `lookup_host_with_cache()`: Don't return empty address list ([#7596](https://github.com/chatmail/core/pull/7596)).
### Fixes
- `get_chat_msgs_ex()`: Don't match on "S=" (Cmd) in param payload.
- Remove `SecurejoinWait` info message when received Alice's key ([#7585](https://github.com/chatmail/core/pull/7585)).
- Do not set normalized name for existing chats and contacts in a migration.
- Remove now redundant "used_account_settings" and "entered_account_settings" from `Context.get_info()` ([#7587](https://github.com/chatmail/core/pull/7587)).
- Don't use fallback servers if got TURN servers from IMAP METADATA.
- Use fallback ICE servers if server can't IMAP METADATA ([#7382](https://github.com/chatmail/core/pull/7382)).
- Add explicit limit for adding relays (5 at the moment) ([#7611](https://github.com/chatmail/core/pull/7611)).
- Take `transport_id` into account when using `imap` table.
### CI
- Update Rust to 1.92.0.
### Miscellaneous Tasks
- Apply Rust 1.92.0 clippy suggestions.
### Other
- Log entered login params and actual used params on configuration failure ([#7610](https://github.com/chatmail/core/pull/7610)).
## [2.34.0] - 2025-12-11
### API-Changes
- rpc-client: Accept `Account` for `Chat.{add,remove}_contact()`.
- rpc-client: Add `Chat.num_contacts()`.
- Forwarding messages to another profile ([#7491](https://github.com/chatmail/core/pull/7491)).
### Features / Changes
- Double ringing time to 120 seconds.
- Better logging for failing securejoin messages ([#7593](https://github.com/chatmail/core/pull/7593)).
- Add multi-transport information to `Context.get_info` ([#7583](https://github.com/chatmail/core/pull/7583))
### Fixes
- Multi-transport: all transports were shown as "inbox" in connectivity view, now they are shown by their hostname ([#7582](https://github.com/chatmail/core/pull/7582)).
- Multi-transport: Synchronize primary transport immediately after changing it.
- Use u64 instead of usize to calculate storage usage.
- Use u64 to represent the number of bytes in backup files.
- Use u64 to count the number of bytes sent/received over the network.
- Use logging macros instead of emitting event directly, so that it is also logged by tracing ([#7459](https://github.com/chatmail/core/pull/7459)).
- Let securejoin succeed even if the chat was deleted in the meantime ([#7594](https://github.com/chatmail/core/pull/7594)).
### Miscellaneous Tasks
- Add RUSTSEC-2025-0134 exception to deny.toml.
### Refactor
- Use u16 instead of usize to represent progress bar.
- Remove EncryptHelper.prefer_encrypt.
- Add params when forwarding message instead of removing unneeded ones.
### Tests
- Port test_synchronize_member_list_on_group_rejoin to JSON-RPC.
- Test setting up second device between core versions.
## [2.33.0] - 2025-12-05
### Features / Changes
- Case-insensitive search for non-ASCII chat and contact names ([#7477](https://github.com/chatmail/core/pull/7477)).
### Fixes
- Recognize all transport addresses as own addresses.
## [2.32.0] - 2025-12-04
Version bump to trigger publishing of npm prebuilds
that failed to be published for 2.31.0 due to not configured "trusted publishers".
### Features / Changes
- Lookup_or_create_adhoc_group(): Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
## [2.31.0] - 2025-12-04
### CI
- Update npm before publishing packages.
### Features / Changes
- Use v2 SEIPD when sending messages to self.
## [2.30.0] - 2025-12-04
### Features / Changes
- Disable SNI for STARTTLS ([#7499](https://github.com/chatmail/core/pull/7499)).
- Introduce cross-core testing along with improvements to test frameworking.
- Synchronize transports via sync messages.
### Fixes
- Fix shutdown shortly after call.
### API-Changes
- Add `TransportsModified` event (for tests).
### CI
- Use "trusted publishing" for NPM packages.
### Miscellaneous Tasks
- deps: Bump actions/checkout from 5 to 6.
- cargo: Bump syn from 2.0.110 to 2.0.111.
- deps: Bump astral-sh/setup-uv from 7.1.3 to 7.1.4.
- cargo: Bump sdp from 0.8.0 to 0.10.0.
- Remove two outdated todo comments ([#7550](https://github.com/chatmail/core/pull/7550)).
## [2.29.0] - 2025-12-01
### API-Changes
- deltachat-rpc-client: Add Message.exists().
### Features / Changes
- [**breaking**] Increase backup version from 3 to 4.
- Hide `To` header in encrypted messages.
- `deltachat_rpc_client.Rpc` accepts `rpc_server_path` for using a particular deltachat-rpc-server ([#7493](https://github.com/chatmail/core/pull/7493)).
- Don't send `Chat-Group-Avatar` header in unencrypted groups.
- Don't update `self-{avatar,status}` from received messages ([#7002](https://github.com/chatmail/core/pull/7002)).
### Fixes
- `CREATE INDEX imap_only_rfc724_mid ON imap(rfc724_mid)` ([#7490](https://github.com/chatmail/core/pull/7490)).
- Use the same webxdc ratelimit for all email servers.
- Handle the case when account does not exist in `get_existing_msg_ids()`.
- Don't send self-avatar in unencrypted messages ([#7136](https://github.com/chatmail/core/pull/7136)).
- Do not configure folders during transport configuration.
- Upload sync messages only with the primary transport.
- Do not use deprecated ConfiguredProvider in get_configured_provider.
### Build system
- Make scripts for remote testing usable.
- Increase minimum supported Python version to 3.10.
- Use SPDX license expression in Python package metadata.
### CI
- Set timeout-minutes for all jobs in ci.yaml workflow.
- Do not install Python manually to bulid RPC server wheels.
- Do not build fake RPC server source packages.
- Build Python wheels in separate jobs.
### Refactor
- [**breaking**] Remove some unneeded stock strings ([#7496](https://github.com/chatmail/core/pull/7496)).
- Strike events in rpc-client request handling, get result from queue.
- Use ConfiguredProvider config directly when loading legacy settings.
- Remove update_icons and disable_server_delete migrations.
- Use `SYMMETRIC_KEY_ALGORITHM` constant in `symm_encrypt_message()`.
- Make signing key non-optional for `pk_encrypt`.
### Tests
- `test_remove_member_bcc`: Test unencrypted group as it was initially.
### Miscellaneous Tasks
- deps: Bump cachix/install-nix-action from 31.8.1 to 31.8.4.
- cargo: Bump hyper from 1.7.0 to 1.8.1.
- cargo: Bump human-panic from 2.0.3 to 2.0.4.
- cargo: Bump hyper-util from 0.1.17 to 0.1.18.
- cargo: Bump rusqlite from 0.36.0 to 0.37.0.
- cargo: Bump tokio-util from 0.7.16 to 0.7.17.
- cargo: Bump toml from 0.9.7 to 0.9.8.
- cargo: Bump proptest from 1.8.0 to 1.9.0.
- cargo: Bump parking_lot from 0.12.4 to 0.12.5.
- cargo: Bump syn from 2.0.106 to 2.0.110.
- cargo: Bump quick-xml from 0.38.3 to 0.38.4.
- cargo: Bump rustls-pki-types from 1.12.0 to 1.13.0.
- cargo: Bump nu-ansi-term from 0.50.1 to 0.50.3.
- cargo: Bump sanitize-filename from 0.5.0 to 0.6.0.
- cargo: Bump quote from 1.0.41 to 1.0.42.
- cargo: Bump libc from 0.2.176 to 0.2.177.
- cargo: Bump bytes from 1.10.1 to 1.11.0.
- cargo: Bump image from 0.25.8 to 0.25.9.
- cargo: Bump rand from 0.9.0 to 0.9.2 ([#7501](https://github.com/chatmail/core/pull/7501)).
- cargo: Bump tokio from 1.45.1 to 1.48.0.
## [2.28.0] - 2025-11-23
### API-Changes
- New API `get_existing_msg_ids()` to check if the messages with given IDs exist.
- Add API to get storage usage information. (JSON-RPC method: `get_storage_usage_report_string`) ([#7486](https://github.com/chatmail/core/pull/7486)).
### Features / Changes
- Experimentaly allow adding second transport.
There is no synchronization yet, so UIs should not allow the user to change the address manually and only expose the ability to add transports if `bcc_self` is disabled.
- Default `bcc_self` to 0 for all new accounts.
- Rephrase "Establishing end-to-end encryption" -> "Establishing connection".
- Stock string for joining a channel ([#7480](https://github.com/chatmail/core/pull/7480)).
### Fixes
- Limit the range of `Date` to up to 6 days in the past.
- `ContactId::set_name_ex()`: Emit ContactsChanged when transaction is completed.
- Set SQLite busy timeout to 1 minute on iOS.
- Sort system messages to the bottom of the chat.
- Assign outgoing self-sent unencrypted messages to ad-hoc groups with only SELF ([#7409](https://github.com/chatmail/core/pull/7409)).
- Add missing stock strings.
- Look up or create ad-hoc group if there are duplicate addresses in "To".
### Documentation
- Add missing RFC 9788, link 'Header Protection for Cryptographically Protected Email' as other RFC.
- Remove unsupported RFC 3503 (`$MDNSent` flag) from the list of standards.
- Mark database encryption support as deprecated ([#7403](https://github.com/chatmail/core/pull/7403)).
### Build system
- Increase Minimum Supported Rust Version to 1.88.0.
- Update rPGP from 0.17.0 to 0.18.0.
- nix: Update `fenix` and use it for all Rust builds.
### CI
- Do not use --encoding option for rst-lint.
### Refactor
- Use `HashMap::extract_if()` stabilized in Rust 1.88.0.
- Remove some easy to remove unwrap() calls.
### Tests
- Contact shalln't be verified by another having unknown verifier.
## [2.27.0] - 2025-11-16
### API-Changes
- Add APIs to stop background fetch.
- [**breaking**]: rename JSON-RPC method accounts_background_fetch() into background_fetch()
- rpc-client: Add APIs for background fetch.
- rpc-client: Add Account.wait_for_msg().
- Deprecate deletion timer string for '1 Minute'.
### Features / Changes
- Implement RFC 9788 (Header Protection for Cryptographically Protected Email) ([#7130](https://github.com/chatmail/core/pull/7130)).
- Tweak initial info-message for unencrypted chats ([#7427](https://github.com/chatmail/core/pull/7427)).
- Add Contact::get_or_gen_color. Use it in CFFI and JSON-RPC to avoid gray self-color ([#7374](https://github.com/chatmail/core/pull/7374)).
- [**breaking**] Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. ([#7439](https://github.com/chatmail/core/pull/7439)).
### Fixes
- Set `get_max_smtp_rcpt_to` for chatmail to the actual limit of 1000 instead of unlimited. ([#7432](https://github.com/chatmail/core/pull/7432)).
- Always set bcc_self on backup import/export.
- Escape connectivity HTML.
- Send webm as file, it is not supported by all UI.
### Build system
- nix: Exclude CONTRIBUTING.md from the source files.
### Refactor
- Use wait_for_incoming_msg() in more tests.
### Tests
- Fix flaky test_send_receive_locations.
- Port folder-related CFFI tests to JSON-RPC.
- HP-Outer headers are added to messages with standard Header Protection ([#7130](https://github.com/chatmail/core/pull/7130)).
- rpc-client: Test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist ([#7442](https://github.com/chatmail/core/pull/7442)).
- Add pytest fixture for account manager.
- Test background_fetch() and stop_background_fetch().
## [2.26.0] - 2025-11-11
### API-Changes
- [**breaking**] JSON-RPC: `chat_type` now contains a variant of a string enum/union. Affected places: `FullChat.chat_type`, `BasicChat.chat_type`, `ChatListItemFetchResult::ChatListItem.chat_type`, `Event:: SecurejoinInviterProgress.chat_type` and `MessageSearchResult.chat_type` ([#7285](https://github.com/chatmail/core/pull/7285))
### Features / Changes
- Error toast for "Not creating securejoin QR for old broadcast".
### Fixes
- `is_encrypted()` should be true for Saved Messages chat so messages there are editable.
- Do not return an error from `receive_imf` if we fail to add a member because we are not in chat.
- Do not add QR inviter to groups immediately.
- Do not ignore I/O errors in `BlobObject::store_from_base64`.
### Miscellaneous Tasks
- Rustfmt.
### Refactor
- imap: Move resync request from Context to Imap.
- Replace imap:: calls in migration 73 with SQL queries.
- Remove unused imports.
### Documentation
- Readme: update language binding section to avoid usage of cffi in new projects ([#7380](https://github.com/chatmail/core/pull/7380)).
- Fix Context::set_stock_translation reference.
### Tests
- Test editing saved messages.
- Remove ThreadPoolExecutor from test_wait_next_messages.
- Move test_two_group_securejoins from receive_imf to securejoin module.
- At the end of securejoin Bob has two members in a group chat.
- Bob has 0 members in the chat until securejoin finishes.
- Do not add QR inviter to groups right after scanning the code.
## [2.25.0] - 2025-11-05
### Features / Changes
@@ -7101,3 +7439,13 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.23.0]: https://github.com/chatmail/core/compare/v2.22.0..v2.23.0
[2.24.0]: https://github.com/chatmail/core/compare/v2.23.0..v2.24.0
[2.25.0]: https://github.com/chatmail/core/compare/v2.24.0..v2.25.0
[2.26.0]: https://github.com/chatmail/core/compare/v2.25.0..v2.26.0
[2.27.0]: https://github.com/chatmail/core/compare/v2.26.0..v2.27.0
[2.28.0]: https://github.com/chatmail/core/compare/v2.27.0..v2.28.0
[2.29.0]: https://github.com/chatmail/core/compare/v2.28.0..v2.29.0
[2.30.0]: https://github.com/chatmail/core/compare/v2.29.0..v2.30.0
[2.31.0]: https://github.com/chatmail/core/compare/v2.30.0..v2.31.0
[2.32.0]: https://github.com/chatmail/core/compare/v2.31.0..v2.32.0
[2.33.0]: https://github.com/chatmail/core/compare/v2.32.0..v2.33.0
[2.34.0]: https://github.com/chatmail/core/compare/v2.33.0..v2.34.0
[2.35.0]: https://github.com/chatmail/core/compare/v2.34.0..v2.35.0

View File

@@ -1,4 +1,4 @@
# Contributing to Delta Chat
# Contributing to chatmail core
## Bug reports

378
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "deltachat"
version = "2.25.0"
version = "2.35.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"
rust-version = "1.88"
repository = "https://github.com/chatmail/core"
[profile.dev]
@@ -78,7 +78,7 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.17.0", default-features = false }
pgp = { version = "0.18.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.38", features = ["escape-html"] }
@@ -88,7 +88,7 @@ regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rustls-pki-types = "1.12.0"
sanitize-filename = { workspace = true }
sdp = "0.8.0"
sdp = "0.10.0"
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
@@ -111,6 +111,7 @@ toml = "0.9"
tracing = "0.1.41"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
walkdir = "2.5.0"
webpki-roots = "0.26.8"
[dev-dependencies]
@@ -194,14 +195,14 @@ nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.9"
regex = "1.10"
rusqlite = "0.36"
sanitize-filename = "0.5"
rusqlite = "0.37"
sanitize-filename = "0.6"
serde = "1.0"
serde_json = "1"
tempfile = "3.23.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.16"
tokio-util = "0.7.17"
tracing-subscriber = "0.3"
yerpc = "0.6.4"

View File

@@ -16,7 +16,8 @@ id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT DEFAULT '' NOT NULL -- message text
) STRICT",
)
.await?;
.await
.context("CREATE TABLE messages")?;
```
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
@@ -29,7 +30,8 @@ id INTEGER PRIMARY KEY AUTOINCREMENT, \
text TEXT DEFAULT '' NOT NULL \
) STRICT",
)
.await?;
.await
.context("CREATE TABLE messages")?;
```
Escaping newlines
is prone to errors like this if space before backslash is missing:
@@ -63,6 +65,9 @@ an older version. Also don't change the column type, consider adding a new colum
instead. Finally, never change column semantics, this is especially dangerous because the `STRICT`
keyword doesn't help here.
Consider adding context to `anyhow` errors for SQL statements using `.context()` so that it's
possible to understand from logs which statement failed. See [Errors](#errors) for more info.
## Errors
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.

View File

@@ -38,7 +38,7 @@ use deltachat::{
internals_for_benches::key_from_asc,
internals_for_benches::parse_and_get_text,
internals_for_benches::store_self_keypair,
pgp::{KeyPair, decrypt, pk_encrypt, symm_encrypt_message},
pgp::{KeyPair, SeipdVersion, decrypt, pk_encrypt, symm_encrypt_message},
stock_str::StockStrings,
};
use rand::{Rng, rng};
@@ -108,9 +108,10 @@ fn criterion_benchmark(c: &mut Criterion) {
pk_encrypt(
plain.clone(),
vec![black_box(key_pair.public.clone())],
Some(key_pair.secret.clone()),
key_pair.secret.clone(),
true,
true,
SeipdVersion::V2,
)
.await
.unwrap()

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.25.0"
version = "2.35.0"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"

View File

@@ -247,7 +247,7 @@ typedef struct _dc_event_emitter dc_accounts_event_emitter_t;
// create/open/config/information
/**
* Create a new context object and try to open it without passphrase. If
* Create a new context object and try to open it. If
* database is encrypted, the result is the same as using
* dc_context_new_closed() and the database should be opened with
* dc_context_open() before using.
@@ -283,8 +283,13 @@ dc_context_t* dc_context_new_closed (const char* dbfile);
/**
* Opens the database with the given passphrase. This can only be used on
* closed context, such as created by dc_context_new_closed(). If the database
* Opens the database with the given passphrase.
* NB: Nonempty passphrase (db encryption) is deprecated 2025-11:
* - Db encryption does nothing with blobs, so fs/disk encryption is recommended.
* - Isolation from other apps is needed anyway.
*
* This can only be used on closed context, such as
* created by dc_context_new_closed(). If the database
* is new, this operation sets the database passphrase. For existing databases
* the passphrase should be the one used to encrypt the database the first
* time.
@@ -301,6 +306,8 @@ int dc_context_open (dc_context_t *context, const char*
/**
* 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.
*
@@ -433,12 +440,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* spam folder and `sendbox_watch` will also still be respected
* if enabled.
* 0=watch all folders normally (default)
* - `show_emails` = DC_SHOW_EMAILS_OFF (0)=
* show direct replies to chats only,
* DC_SHOW_EMAILS_ACCEPTED_CONTACTS (1)=
* also show all mails of confirmed contacts,
* DC_SHOW_EMAILS_ALL (2)=
* also show mails of unconfirmed contacts (default).
* - `delete_device_after` = 0=do not delete messages from device automatically (default),
* >=1=seconds, after which messages are deleted automatically from the device.
* Messages in the "saved messages" chat (see dc_chat_is_self_talk()) are skipped.
@@ -447,8 +448,7 @@ char* dc_get_blobdir (const dc_context_t* context);
* - `delete_server_after` = 0=do not delete messages from server automatically (default),
* 1=delete messages directly after receiving from server, mvbox is skipped.
* >1=seconds, after which messages are deleted automatically from the server, mvbox is used as defined.
* "Saved messages" are deleted from the server as well as
* e-mails matching the `show_emails` settings above, the UI should clearly point that out.
* "Saved messages" are deleted from the server as well as emails, the UI should clearly point that out.
* See also dc_estimate_deletion_cnt().
* - `media_quality` = DC_MEDIA_QUALITY_BALANCED (0) =
* good outgoing images/videos/voice quality at reasonable sizes (default)
@@ -1471,7 +1471,6 @@ dc_chatlist_t* dc_get_similar_chatlist (dc_context_t* context, uint32_t ch
* @param from_server 1=Estimate deletion count for server, 0=Estimate deletion count for device
* @param seconds Count messages older than the given number of seconds.
* @return Number of messages that are older than the given number of seconds.
* This includes e-mails downloaded due to the `show_emails` option.
* Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
*/
int dc_estimate_deletion_cnt (dc_context_t* context, int from_server, int64_t seconds);
@@ -2578,8 +2577,10 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_ERROR 400 // text1=error string
#define DC_QR_WITHDRAW_VERIFYCONTACT 500
#define DC_QR_WITHDRAW_VERIFYGROUP 502 // text1=groupname
#define DC_QR_WITHDRAW_JOINBROADCAST 504 // text1=broadcast name
#define DC_QR_REVIVE_VERIFYCONTACT 510
#define DC_QR_REVIVE_VERIFYGROUP 512 // text1=groupname
#define DC_QR_REVIVE_JOINBROADCAST 514 // text1=broadcast name
#define DC_QR_LOGIN 520 // text1=email_address
/**
@@ -3296,12 +3297,30 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts);
* without forgetting to create notifications caused by timing race conditions.
*
* @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.
*/
int dc_accounts_background_fetch (dc_accounts_t* accounts, uint64_t timeout);
/**
* Stop ongoing background fetch.
*
* Calling this function allows to stop dc_accounts_background_fetch() early.
* dc_accounts_background_fetch() will then return immediately
* and emit DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE unless
* if it has failed and returned 0.
*
* If there is no ongoing dc_accounts_background_fetch() call,
* calling this function does nothing.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
*/
void dc_accounts_stop_background_fetch (dc_accounts_t *accounts);
/**
* Sets device token for Apple Push Notification service.
* Returns immediately.
@@ -6685,14 +6704,6 @@ void dc_event_unref(dc_event_t* event);
#define DC_EVENT_DATA2_IS_STRING(e) ((e)==DC_EVENT_CONFIGURE_PROGRESS || (e)==DC_EVENT_IMEX_FILE_WRITTEN || ((e)>=100 && (e)<=499))
/*
* Values for dc_get|set_config("show_emails")
*/
#define DC_SHOW_EMAILS_OFF 0
#define DC_SHOW_EMAILS_ACCEPTED_CONTACTS 1
#define DC_SHOW_EMAILS_ALL 2
/*
* Values for dc_get|set_config("media_quality")
*/
@@ -7188,11 +7199,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as device message text.
#define DC_STR_SELF_DELETED_MSG_BODY 91
/// "'Delete messages from server' turned off as now all folders are affected."
///
/// Used as device message text.
#define DC_STR_SERVER_TURNED_OFF 92
/// "Message deletion timer is set to %1$s minutes."
///
/// Used in status messages.
@@ -7389,19 +7395,6 @@ void dc_event_unref(dc_event_t* event);
/// @deprecated 2025-06-05
#define DC_STR_AEAP_ADDR_CHANGED 122
/// "You changed your email address from %1$s to %2$s.
/// If you now send a message to a group, contacts there will automatically
/// replace the old with your new address.\n\n It's highly advised to set up
/// your old email provider to forward all emails to your new email address.
/// Otherwise you might miss messages of contacts who did not get your new
/// address yet." + the link to the AEAP blog post
///
/// As soon as there is a post about AEAP, the UIs should add it:
/// set_stock_translation(123, getString(aeap_explanation) + "\n\n" + AEAP_BLOG_LINK)
///
/// Used in a device message that explains AEAP.
#define DC_STR_AEAP_EXPLANATION_AND_LINK 123
/// "You changed group name from \"%1$s\" to \"%2$s\"."
///
/// `%1$s` will be replaced by the old group name.
@@ -7518,14 +7511,13 @@ void dc_event_unref(dc_event_t* event);
/// "You set message deletion timer to 1 minute."
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU 142
/// "Message deletion timer is set to 1 minute by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER 143
/// "You set message deletion timer to 1 hour."
@@ -7651,12 +7643,6 @@ void dc_event_unref(dc_event_t* event);
/// Used in info messages.
#define DC_STR_CHAT_PROTECTION_ENABLED 170
/// "%1$s sent a message from another device."
///
/// Used in info messages.
/// @deprecated 2025-07
#define DC_STR_CHAT_PROTECTION_DISABLED 171
/// "Others will only see this group after you sent a first message."
///
/// Used as the first info messages in newly created groups.
@@ -7693,7 +7679,12 @@ void dc_event_unref(dc_event_t* event);
/// Used in summaries.
#define DC_STR_REACTED_BY 177
/// "Establishing guaranteed end-to-end encryption, please wait…"
/// "Member %1$s removed."
///
/// `%1$s` will be replaced by name of the removed contact.
#define DC_STR_REMOVE_MEMBER 178
/// "Establishing connection, please wait…"
///
/// Used as info message.
#define DC_STR_SECUREJOIN_WAIT 190
@@ -7737,7 +7728,22 @@ void dc_event_unref(dc_event_t* event);
/// Subtitle for channel join qrcode svg image generated by the core.
///
/// `%1$s` will be replaced with the channel name.
#define DC_STR_SECURE_JOIN_CHANNEL_QR_DESC 201
#define DC_STR_SECURE_JOIN_CHANNEL_QR_DESC 201
/// "You joined the channel."
#define DC_STR_MSG_YOU_JOINED_CHANNEL 202
/// "%1$s invited you to join this channel. Waiting for the device of %2$s to reply…"
///
/// Added as an info-message directly after scanning a QR code for joining a broadcast channel.
///
/// `%1$s` and `%2$s` will both be replaced by the name of the inviter.
#define DC_STR_SECURE_JOIN_CHANNEL_STARTED 203
/// "The attachment contains anonymous usage statistics, which help us improve Delta Chat. Thank you!"
///
/// Used as the message body for statistics sent out.
#define DC_STR_STATS_MSG_BODY 210
/// "Proxy Enabled"
///
@@ -7749,6 +7755,11 @@ void dc_event_unref(dc_event_t* event);
/// Description in connectivity view when proxy is enabled.
#define DC_STR_PROXY_ENABLED_DESCRIPTION 221
/// "Messages in this chat use classic email and are not encrypted."
///
/// Used as the first info messages in newly created classic email threads.
#define DC_STR_CHAT_UNENCRYPTED_EXPLANATON 230
/**
* @}
*/

View File

@@ -559,6 +559,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::IncomingCallAccepted { .. } => 2560,
EventType::OutgoingCallAccepted { .. } => 2570,
EventType::CallEnded { .. } => 2580,
EventType::TransportsModified => 2600,
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
@@ -593,7 +594,8 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
| EventType::AccountsBackgroundFetchDone
| EventType::ChatlistChanged
| EventType::AccountsChanged
| EventType::AccountsItemChanged => 0,
| EventType::AccountsItemChanged
| EventType::TransportsModified => 0,
EventType::IncomingReaction { contact_id, .. }
| EventType::IncomingWebxdcNotify { contact_id, .. } => contact_id.to_u32() as libc::c_int,
EventType::MsgsChanged { chat_id, .. }
@@ -681,7 +683,8 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. } => 0,
| EventType::EventChannelOverflow { .. }
| EventType::TransportsModified => 0,
EventType::MsgsChanged { msg_id, .. }
| EventType::ReactionsChanged { msg_id, .. }
| EventType::IncomingReaction { msg_id, .. }
@@ -780,7 +783,8 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::AccountsChanged
| EventType::AccountsItemChanged
| EventType::IncomingCallAccepted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. } => ptr::null_mut(),
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::TransportsModified => ptr::null_mut(),
EventType::IncomingCall {
place_call_info, ..
} => {
@@ -4240,7 +4244,17 @@ pub unsafe extern "C" fn dc_contact_get_color(contact: *mut dc_contact_t) -> u32
return 0;
}
let ffi_contact = &*contact;
ffi_contact.contact.get_color()
let ctx = &*ffi_contact.context;
block_on(async move {
ffi_contact
.contact
// We don't want any UIs displaying gray self-color.
.get_or_gen_color(ctx)
.await
.context("Contact::get_color()")
.log_err(ctx)
.unwrap_or(0)
})
}
#[no_mangle]
@@ -5017,6 +5031,17 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
1
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_stop_background_fetch(accounts: *mut dc_accounts_t) {
if accounts.is_null() {
eprintln!("ignoring careless call to dc_accounts_stop_background_fetch()");
return;
}
let accounts = &*accounts;
block_on(accounts.read()).stop_background_fetch();
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_set_push_device_token(
accounts: *mut dc_accounts_t,

View File

@@ -58,8 +58,10 @@ impl Lot {
Qr::Text { text } => Some(Cow::Borrowed(text)),
Qr::WithdrawVerifyContact { .. } => None,
Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::WithdrawJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::ReviveVerifyContact { .. } => None,
Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::ReviveJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::Login { address, .. } => Some(Cow::Borrowed(address)),
},
Self::Error(err) => Some(Cow::Borrowed(err)),
@@ -112,8 +114,10 @@ impl Lot {
Qr::Text { .. } => LotState::QrText,
Qr::WithdrawVerifyContact { .. } => LotState::QrWithdrawVerifyContact,
Qr::WithdrawVerifyGroup { .. } => LotState::QrWithdrawVerifyGroup,
Qr::WithdrawJoinBroadcast { .. } => LotState::QrWithdrawJoinBroadcast,
Qr::ReviveVerifyContact { .. } => LotState::QrReviveVerifyContact,
Qr::ReviveVerifyGroup { .. } => LotState::QrReviveVerifyGroup,
Qr::ReviveJoinBroadcast { .. } => LotState::QrReviveJoinBroadcast,
Qr::Login { .. } => LotState::QrLogin,
},
Self::Error(_err) => LotState::QrError,
@@ -138,9 +142,11 @@ impl Lot {
Qr::Url { .. } => Default::default(),
Qr::Text { .. } => Default::default(),
Qr::WithdrawVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::WithdrawVerifyGroup { .. } => Default::default(),
Qr::WithdrawVerifyGroup { .. } | Qr::WithdrawJoinBroadcast { .. } => {
Default::default()
}
Qr::ReviveVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::ReviveVerifyGroup { .. } => Default::default(),
Qr::ReviveVerifyGroup { .. } | Qr::ReviveJoinBroadcast { .. } => Default::default(),
Qr::Login { .. } => Default::default(),
},
Self::Error(_) => Default::default(),
@@ -207,11 +213,15 @@ pub enum LotState {
/// text1=groupname
QrWithdrawVerifyGroup = 502,
/// text1=broadcast channel name
QrWithdrawJoinBroadcast = 504,
QrReviveVerifyContact = 510,
/// text1=groupname
QrReviveVerifyGroup = 512,
/// text1=groupname
QrReviveJoinBroadcast = 514,
/// text1=email_address
QrLogin = 520,

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.25.0"
version = "2.35.0"
description = "DeltaChat JSON-RPC API"
edition = "2021"
license = "MPL-2.0"
@@ -19,7 +19,6 @@ yerpc = { workspace = true, features = ["anyhow_expose", "openrpc"] }
typescript-type-def = { version = "0.5.13", features = ["json_value"] }
tokio = { workspace = true }
sanitize-filename = { workspace = true }
walkdir = "2.5.0"
base64 = { workspace = true }
[dev-dependencies]

View File

@@ -10,8 +10,9 @@ pub use deltachat::accounts::Accounts;
use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, get_chat_media, get_chat_msgs, get_chat_msgs_ex,
marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
get_chat_msgs_ex, marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem,
MessageListOptions,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::Config;
@@ -21,9 +22,9 @@ use deltachat::context::get_info;
use deltachat::ephemeral::Timer;
use deltachat::imex;
use deltachat::location;
use deltachat::message::get_msg_read_receipts;
use deltachat::message::{
self, delete_msgs_ex, markseen_msgs, Message, MessageState, MsgId, Viewtype,
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipts, markseen_msgs, Message,
MessageState, MsgId, Viewtype,
};
use deltachat::peer_channels::{
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
@@ -34,13 +35,13 @@ use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
use deltachat::reaction::{get_msg_reactions, send_reaction};
use deltachat::securejoin;
use deltachat::stock_str::StockMessage;
use deltachat::storage_usage::{get_blobdir_storage_usage, get_storage_usage};
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::EventEmitter;
use sanitize_filename::is_sanitized;
use tokio::fs;
use tokio::sync::{watch, Mutex, RwLock};
use types::login_param::EnteredLoginParam;
use walkdir::WalkDir;
use yerpc::rpc;
pub mod types;
@@ -120,14 +121,14 @@ impl CommandApi {
}
}
async fn get_context_opt(&self, id: u32) -> Option<deltachat::context::Context> {
self.accounts.read().await.get_account(id)
}
async fn get_context(&self, id: u32) -> Result<deltachat::context::Context> {
let sc = self
.accounts
.read()
self.get_context_opt(id)
.await
.get_account(id)
.ok_or_else(|| anyhow!("account with id {id} not found"))?;
Ok(sc)
.ok_or_else(|| anyhow!("account with id {id} not found"))
}
async fn with_state<F, T>(&self, id: u32, with_state: F) -> T
@@ -273,7 +274,7 @@ impl CommandApi {
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout.
/// 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.
async fn accounts_background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
let future = {
let lock = self.accounts.read().await;
lock.background_fetch(std::time::Duration::from_secs_f64(timeout_in_seconds))
@@ -283,6 +284,11 @@ impl CommandApi {
Ok(())
}
async fn stop_background_fetch(&self) -> Result<()> {
self.accounts.read().await.stop_background_fetch();
Ok(())
}
// ---------------------------------------------
// Methods that work on individual accounts
// ---------------------------------------------
@@ -323,13 +329,7 @@ impl CommandApi {
async fn get_account_file_size(&self, account_id: u32) -> Result<u64> {
let ctx = self.get_context(account_id).await?;
let dbfile = ctx.get_dbfile().metadata()?.len();
let total_size = WalkDir::new(ctx.get_blobdir())
.max_depth(2)
.into_iter()
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.metadata().ok())
.filter(|metadata| metadata.is_file())
.fold(0, |acc, m| acc + m.len());
let total_size = get_blobdir_storage_usage(&ctx);
Ok(dbfile + total_size)
}
@@ -361,6 +361,13 @@ impl CommandApi {
ctx.get_info().await
}
/// Get storage usage report as formatted string
async fn get_storage_usage_report_string(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
let storage_usage = get_storage_usage(&ctx).await?;
Ok(storage_usage.to_string())
}
/// Get the blob dir.
async fn get_blob_dir(&self, account_id: u32) -> Result<Option<String>> {
let ctx = self.get_context(account_id).await?;
@@ -1290,6 +1297,24 @@ impl CommandApi {
.collect())
}
/// Checks if the messages with given IDs exist.
///
/// Returns IDs of existing messages.
async fn get_existing_msg_ids(&self, account_id: u32, msg_ids: Vec<u32>) -> Result<Vec<u32>> {
if let Some(context) = self.get_context_opt(account_id).await {
let msg_ids: Vec<MsgId> = msg_ids.into_iter().map(MsgId::new).collect();
let existing_msg_ids = get_existing_msg_ids(&context, &msg_ids).await?;
Ok(existing_msg_ids
.into_iter()
.map(|msg_id| msg_id.to_u32())
.collect())
} else {
// Account does not exist, so messages do not exist either,
// but this is not an error.
Ok(Vec::new())
}
}
async fn get_message_list_items(
&self,
account_id: u32,
@@ -2177,6 +2202,27 @@ impl CommandApi {
forward_msgs(&ctx, &message_ids, ChatId::new(chat_id)).await
}
/// Forward messages to a chat in another account.
/// See [`Self::forward_messages`] for more info.
async fn forward_messages_to_account(
&self,
src_account_id: u32,
src_message_ids: Vec<u32>,
dst_account_id: u32,
dst_chat_id: u32,
) -> Result<()> {
let src_ctx = self.get_context(src_account_id).await?;
let dst_ctx = self.get_context(dst_account_id).await?;
let src_message_ids: Vec<MsgId> = src_message_ids.into_iter().map(MsgId::new).collect();
forward_msgs_2ctx(
&src_ctx,
&src_message_ids,
&dst_ctx,
ChatId::new(dst_chat_id),
)
.await
}
/// Resend messages and make information available for newly added chat members.
/// Resending sends out the original message, however, recipients and webxdc-status may differ.
/// Clients that already have the original message can still ignore the resent message as

View File

@@ -15,7 +15,7 @@ pub enum Account {
display_name: Option<String>,
addr: Option<String>,
// size: u32,
profile_image: Option<String>, // TODO: This needs to be converted to work with blob http server.
profile_image: Option<String>,
color: String,
/// Optional tag as "Work", "Family".
/// Meant to help profile owner to differ between profiles with similar names.
@@ -32,7 +32,10 @@ impl Account {
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).await?.get_color(),
Contact::get_by_id(ctx, ContactId::SELF)
.await?
.get_or_gen_color(ctx)
.await?,
);
let private_tag = ctx.get_config(Config::PrivateTag).await?;
Ok(Account::Configured {

View File

@@ -69,7 +69,7 @@ pub struct FullChat {
// but that would be an extra DB query.
self_in_group: bool,
is_muted: bool,
ephemeral_timer: u32, //TODO look if there are more important properties in newer core versions
ephemeral_timer: u32,
can_send: bool,
was_seen_recently: bool,
mailing_list_address: Option<String>,

View File

@@ -271,7 +271,7 @@ pub enum EventType {
/// Progress.
///
/// 0=error, 1-999=progress in permille, 1000=success and done
progress: usize,
progress: u16,
/// Progress comment or error, something to display to the user.
comment: Option<String>,
@@ -282,7 +282,7 @@ pub enum EventType {
#[serde(rename_all = "camelCase")]
ImexProgress {
/// 0=error, 1-999=progress in permille, 1000=success and done
progress: usize,
progress: u16,
},
/// A file has been exported. A file has been written by imex().
@@ -313,7 +313,7 @@ pub enum EventType {
chat_id: u32,
/// Progress, always 1000.
progress: usize,
progress: u16,
},
/// Progress information of a secure-join handshake from the view of the joiner
@@ -329,7 +329,7 @@ pub enum EventType {
/// 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)
/// 1000=vg-member-added/vc-contact-confirm received
progress: usize,
progress: u16,
},
/// The connectivity to the server changed.
@@ -460,6 +460,15 @@ pub enum EventType {
/// ID of the chat which the message belongs to.
chat_id: u32,
},
/// One or more transports has changed.
///
/// This event is used for tests to detect when transport
/// synchronization messages arrives.
/// UIs don't need to use it, it is unlikely
/// that user modifies transports on multiple
/// devices simultaneously.
TransportsModified,
}
impl From<CoreEventType> for EventType {
@@ -642,6 +651,8 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
},
CoreEventType::TransportsModified => TransportsModified,
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),

View File

@@ -157,6 +157,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to withdraw their own broadcast channel invite QR code.
WithdrawJoinBroadcast {
/// Broadcast name.
name: String,
/// ID, uniquely identifying this chat. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own QR code.
ReviveVerifyContact {
/// Contact ID.
@@ -183,6 +198,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own broadcast channel invite QR code.
ReviveJoinBroadcast {
/// Broadcast name.
name: String,
/// Globally unique chat ID. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// `dclogin:` scheme parameters.
///
/// Ask the user if they want to login with the email address.
@@ -306,6 +336,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::ReviveVerifyContact {
contact_id,
fingerprint,
@@ -340,6 +389,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::Login { address, .. } => QrObject::Login { address },
}
}

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.25.0"
"version": "2.35.0"
}

View File

@@ -64,6 +64,7 @@ describe("online tests", function () {
await dc.rpc.setConfig(accountId1, "addr", account1.email);
await dc.rpc.setConfig(accountId1, "mail_pw", account1.password);
await dc.rpc.configure(accountId1);
await waitForEvent(dc, "ImapInboxIdle", accountId1);
accountId2 = await dc.rpc.addAccount();
await dc.rpc.batchSetConfig(accountId2, {
@@ -71,6 +72,7 @@ describe("online tests", function () {
mail_pw: account2.password,
});
await dc.rpc.configure(accountId2);
await waitForEvent(dc, "ImapInboxIdle", accountId2);
accountsConfigured = true;
});

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.25.0"
version = "2.35.0"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"

View File

@@ -430,12 +430,12 @@ async fn handle_cmd(
}
"oauth2" => {
if let Some(addr) = ctx.get_config(config::Config::Addr).await? {
let oauth2_url =
get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await?;
if oauth2_url.is_none() {
println!("OAuth2 not available for {}.", &addr);
if let Some(oauth2_url) =
get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await?
{
println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{oauth2_url}");
} else {
println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{}", oauth2_url.unwrap());
println!("OAuth2 not available for {}.", &addr);
}
} else {
println!("oauth2: set addr first.");

View File

@@ -30,6 +30,15 @@ $ pip install .
Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.
## Activating current checkout of deltachat-rpc-client and -server for development
Go to root repository directory and run:
```
$ scripts/make-rpc-testenv.sh
$ source venv/bin/activate
```
## Using in REPL
Setup a development environment:

View File

@@ -1,20 +1,18 @@
[build-system]
requires = ["setuptools>=45"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.25.0"
version = "2.35.0"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
@@ -24,7 +22,7 @@ classifiers = [
"Topic :: Communications :: Email"
]
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.10"
[tool.setuptools.package-data]
deltachat_rpc_client = [

View File

@@ -130,6 +130,10 @@ class Account:
"""Add a new transport using a QR code."""
yield self._rpc.add_transport_from_qr.future(self.id, qr)
def delete_transport(self, addr: str):
"""Delete a transport."""
self._rpc.delete_transport(self.id, addr)
@futuremethod
def list_transports(self):
"""Return the list of all email accounts that are used as a transport in the current profile."""
@@ -417,12 +421,21 @@ class Account:
"""Wait for messages noticed event and return it."""
return self.wait_for_event(EventType.MSGS_NOTICED)
def wait_for_msg(self, event_type) -> Message:
"""Wait for an event about the message.
Consumes all events before the matching event.
Returns a message corresponding to the msg_id field of the event.
"""
event = self.wait_for_event(event_type)
return self.get_message_by_id(event.msg_id)
def wait_for_incoming_msg(self):
"""Wait for incoming message and return it.
Consumes all events before the next incoming message event.
"""
return self.get_message_by_id(self.wait_for_incoming_msg_event().msg_id)
return self.wait_for_msg(EventType.INCOMING_MSG)
def wait_for_securejoin_inviter_success(self):
"""Wait until SecureJoin process finishes successfully on the inviter side."""

View File

@@ -219,10 +219,12 @@ class Chat:
"""Mark all messages in this chat as noticed."""
self._rpc.marknoticed_chat(self.account.id, self.id)
def add_contact(self, *contact: Union[int, str, Contact]) -> None:
def add_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
"""Add contacts to this group."""
from .account import Account
for cnt in contact:
if isinstance(cnt, str):
if isinstance(cnt, (str, Account)):
contact_id = self.account.create_contact(cnt).id
elif not isinstance(cnt, int):
contact_id = cnt.id
@@ -230,10 +232,12 @@ class Chat:
contact_id = cnt
self._rpc.add_contact_to_chat(self.account.id, self.id, contact_id)
def remove_contact(self, *contact: Union[int, str, Contact]) -> None:
def remove_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
"""Remove members from this group."""
from .account import Account
for cnt in contact:
if isinstance(cnt, str):
if isinstance(cnt, (str, Account)):
contact_id = self.account.create_contact(cnt).id
elif not isinstance(cnt, int):
contact_id = cnt.id
@@ -249,6 +253,10 @@ class Chat:
contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in contacts]
def num_contacts(self) -> int:
"""Return number of contacts in this chat."""
return len(self.get_contacts())
def get_past_contacts(self) -> list[Contact]:
"""Get past contacts for this chat."""
past_contacts = self._rpc.get_past_chat_contacts(self.account.id, self.id)

View File

@@ -80,6 +80,7 @@ class EventType(str, Enum):
CONFIG_SYNCED = "ConfigSynced"
WEBXDC_REALTIME_DATA = "WebxdcRealtimeData"
WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED = "WebxdcRealtimeAdvertisementReceived"
TRANSPORTS_MODIFIED = "TransportsModified"
class ChatId(IntEnum):

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ._utils import AttrDict
from ._utils import AttrDict, futuremethod
from .account import Account
if TYPE_CHECKING:
@@ -39,6 +39,15 @@ class DeltaChat:
"""Stop the I/O of all accounts."""
self.rpc.stop_io_for_all_accounts()
@futuremethod
def background_fetch(self, timeout_in_seconds: int) -> None:
"""Run background fetch for all accounts."""
yield self.rpc.background_fetch.future(timeout_in_seconds)
def stop_background_fetch(self) -> None:
"""Stop ongoing background fetch."""
self.rpc.stop_background_fetch()
def maybe_network(self) -> None:
"""Indicate that the network conditions might have changed."""
self.rpc.maybe_network()

View File

@@ -60,6 +60,10 @@ class Message:
"""Mark the message as seen."""
self._rpc.markseen_msgs(self.account.id, [self.id])
def exists(self) -> bool:
"""Return True if the message exists."""
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
"""Continue the Autocrypt Setup Message key transfer.

View File

@@ -3,9 +3,14 @@
from __future__ import annotations
import os
import pathlib
import platform
import random
import subprocess
import sys
from typing import AsyncGenerator, Optional
import execnet
import py
import pytest
@@ -20,6 +25,18 @@ Currently this is "End-to-end encryption available".
"""
def pytest_report_header():
for base in os.get_exec_path():
fn = pathlib.Path(base).joinpath(base, "deltachat-rpc-server")
if fn.exists():
proc = subprocess.Popen([str(fn), "--version"], stderr=subprocess.PIPE)
proc.wait()
version = proc.stderr.read().decode().strip()
return f"deltachat-rpc-server: {fn} [{version}]"
return None
class ACFactory:
"""Test account factory."""
@@ -40,12 +57,17 @@ class ACFactory:
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
return f"{username}@{domain}", f"{username}${username}"
def get_account_qr(self):
"""Return "dcaccount:" QR code for testing chatmail relay."""
domain = os.getenv("CHATMAIL_DOMAIN")
return f"dcaccount:{domain}"
@futuremethod
def new_configured_account(self):
"""Create a new configured account."""
account = self.get_unconfigured_account()
domain = os.getenv("CHATMAIL_DOMAIN")
yield account.add_transport_from_qr.future(f"dcaccount:{domain}")
qr = self.get_account_qr()
yield account.add_transport_from_qr.future(qr)
assert account.is_configured()
return account
@@ -77,6 +99,7 @@ class ACFactory:
ac_clone = self.get_unconfigured_account()
for transport in transports:
ac_clone.add_or_update_transport(transport)
ac_clone.bring_online()
return ac_clone
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:
@@ -135,9 +158,15 @@ def rpc(tmp_path) -> AsyncGenerator:
@pytest.fixture
def acfactory(rpc) -> AsyncGenerator:
def dc(rpc) -> DeltaChat:
"""Return account manager."""
return DeltaChat(rpc)
@pytest.fixture
def acfactory(dc) -> AsyncGenerator:
"""Return account factory fixture."""
return ACFactory(DeltaChat(rpc))
return ACFactory(dc)
@pytest.fixture
@@ -185,3 +214,134 @@ def log():
print(" " + msg)
return Printer()
#
# support for testing against different deltachat-rpc-server/clients
# installed into a temporary virtualenv and connected via 'execnet' channels
#
def find_path(venv, name):
is_windows = platform.system() == "Windows"
bin = venv / ("bin" if not is_windows else "Scripts")
tryadd = [""]
if is_windows:
tryadd += os.environ["PATHEXT"].split(os.pathsep)
for ext in tryadd:
p = bin.joinpath(name + ext)
if p.exists():
return str(p)
return None
@pytest.fixture(scope="session")
def get_core_python_env(tmp_path_factory):
"""Return a factory to create virtualenv environments with rpc server/client packages
installed.
The factory takes a version and returns a (python_path, rpc_server_path) tuple
of the respective binaries in the virtualenv.
"""
envs = {}
def get_versioned_venv(core_version):
venv = envs.get(core_version)
if not venv:
venv = tmp_path_factory.mktemp(f"temp-{core_version}")
subprocess.check_call([sys.executable, "-m", "venv", venv])
python = find_path(venv, "python")
pkgs = [f"deltachat-rpc-server=={core_version}", f"deltachat-rpc-client=={core_version}", "pytest"]
subprocess.check_call([python, "-m", "pip", "install"] + pkgs)
envs[core_version] = venv
python = find_path(venv, "python")
rpc_server_path = find_path(venv, "deltachat-rpc-server")
print(f"python={python}\nrpc_server={rpc_server_path}")
return python, rpc_server_path
return get_versioned_venv
@pytest.fixture
def alice_and_remote_bob(tmp_path, acfactory, get_core_python_env):
"""return local Alice account, a contact to bob, and a remote 'eval' function for bob.
The 'eval' function allows to remote-execute arbitrary expressions
that can use the `bob` online account, and the `bob_contact_alice`.
"""
def factory(core_version):
python, rpc_server_path = get_core_python_env(core_version)
gw = execnet.makegateway(f"popen//python={python}")
accounts_dir = str(tmp_path.joinpath("account1_venv1"))
channel = gw.remote_exec(remote_bob_loop)
cm = os.environ.get("CHATMAIL_DOMAIN")
# trigger getting an online account on bob's side
channel.send((accounts_dir, str(rpc_server_path), cm))
# meanwhile get a local alice account
alice = acfactory.get_online_account()
channel.send(alice.self_contact.make_vcard())
# wait for bob to have started
sysinfo = channel.receive()
assert sysinfo == f"v{core_version}"
bob_vcard = channel.receive()
[alice_contact_bob] = alice.import_vcard(bob_vcard)
def eval(eval_str):
channel.send(eval_str)
return channel.receive()
return alice, alice_contact_bob, eval
return factory
def remote_bob_loop(channel):
# This function executes with versioned
# deltachat-rpc-client/server packages
# installed into the virtualenv.
#
# The "channel" argument is a send/receive pipe
# to the process that runs the corresponding remote_exec(remote_bob_loop)
import os
from deltachat_rpc_client import DeltaChat, Rpc
from deltachat_rpc_client.pytestplugin import ACFactory
accounts_dir, rpc_server_path, chatmail_domain = channel.receive()
os.environ["CHATMAIL_DOMAIN"] = chatmail_domain
# older core versions don't support specifying rpc_server_path
# so we can't just pass `rpc_server_path` argument to Rpc constructor
basepath = os.path.dirname(rpc_server_path)
os.environ["PATH"] = os.pathsep.join([basepath, os.environ["PATH"]])
rpc = Rpc(accounts_dir=accounts_dir)
with rpc:
dc = DeltaChat(rpc)
channel.send(dc.rpc.get_system_info()["deltachat_core_version"])
acfactory = ACFactory(dc)
bob = acfactory.get_online_account()
alice_vcard = channel.receive()
[alice_contact] = bob.import_vcard(alice_vcard)
ns = {"bob": bob, "bob_contact_alice": alice_contact}
channel.send(bob.self_contact.make_vcard())
while 1:
eval_str = channel.receive()
res = eval(eval_str, ns)
try:
channel.send(res)
except Exception:
# some unserializable result
channel.send(None)

View File

@@ -9,7 +9,7 @@ import os
import subprocess
import sys
from queue import Empty, Queue
from threading import Event, Thread
from threading import Thread
from typing import Any, Iterator, Optional
@@ -17,25 +17,6 @@ class JsonRpcError(Exception):
"""JSON-RPC error."""
class RpcFuture:
"""RPC future waiting for RPC call result."""
def __init__(self, rpc: "Rpc", request_id: int, event: Event):
self.rpc = rpc
self.request_id = request_id
self.event = event
def __call__(self):
"""Wait for the future to return the result."""
self.event.wait()
response = self.rpc.request_results.pop(self.request_id)
if "error" in response:
raise JsonRpcError(response["error"])
if "result" in response:
return response["result"]
return None
class RpcMethod:
"""RPC method."""
@@ -57,20 +38,26 @@ class RpcMethod:
"params": args,
"id": request_id,
}
event = Event()
self.rpc.request_events[request_id] = event
self.rpc.request_results[request_id] = queue = Queue()
self.rpc.request_queue.put(request)
return RpcFuture(self.rpc, request_id, event)
def rpc_future():
"""Wait for the request to receive a result."""
response = queue.get()
if "error" in response:
raise JsonRpcError(response["error"])
return response.get("result", None)
return rpc_future
class Rpc:
"""RPC client."""
def __init__(self, accounts_dir: Optional[str] = None, **kwargs):
def __init__(self, accounts_dir: Optional[str] = None, rpc_server_path="deltachat-rpc-server", **kwargs):
"""Initialize RPC client.
The given arguments will be passed to subprocess.Popen().
The 'kwargs' arguments will be passed to subprocess.Popen().
"""
if accounts_dir:
kwargs["env"] = {
@@ -79,13 +66,12 @@ class Rpc:
}
self._kwargs = kwargs
self.rpc_server_path = rpc_server_path
self.process: subprocess.Popen
self.id_iterator: Iterator[int]
self.event_queues: dict[int, Queue]
# Map from request ID to `threading.Event`.
self.request_events: dict[int, Event]
# Map from request ID to the result.
self.request_results: dict[int, Any]
# Map from request ID to a Queue which provides a single result
self.request_results: dict[int, Queue]
self.request_queue: Queue[Any]
self.closing: bool
self.reader_thread: Thread
@@ -94,27 +80,18 @@ class Rpc:
def start(self) -> None:
"""Start RPC server subprocess."""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE}
if sys.version_info >= (3, 11):
self.process = subprocess.Popen(
"deltachat-rpc-server",
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
# Prevent subprocess from capturing SIGINT.
process_group=0,
**self._kwargs,
)
# Prevent subprocess from capturing SIGINT.
popen_kwargs["process_group"] = 0
else:
self.process = subprocess.Popen(
"deltachat-rpc-server",
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
# `process_group` is not supported before Python 3.11.
preexec_fn=os.setpgrp, # noqa: PLW1509
**self._kwargs,
)
# `process_group` is not supported before Python 3.11.
popen_kwargs["preexec_fn"] = os.setpgrp # noqa: PLW1509
popen_kwargs.update(self._kwargs)
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
self.id_iterator = itertools.count(start=1)
self.event_queues = {}
self.request_events = {}
self.request_results = {}
self.request_queue = Queue()
self.closing = False
@@ -149,9 +126,7 @@ class Rpc:
response = json.loads(line)
if "id" in response:
response_id = response["id"]
event = self.request_events.pop(response_id)
self.request_results[response_id] = response
event.set()
self.request_results.pop(response_id).put(response)
else:
logging.warning("Got a response without ID: %s", response)
except Exception:

View File

@@ -85,11 +85,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()
@@ -173,7 +173,6 @@ class DirectImap:
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:*")
@@ -181,14 +180,11 @@ class IdleManager:
def check(self, timeout=None) -> list[bytes]:
"""(blocking) wait for next idle message from server."""
self.log("imap-direct: calling idle_check")
res = self.direct_imap.conn.idle.poll(timeout=timeout)
self.log(f"imap-direct: idle_check returned {res!r}")
return res
return self.direct_imap.conn.idle.poll(timeout=timeout)
def wait_for_new_message(self, timeout=None) -> bytes:
def wait_for_new_message(self) -> bytes:
while True:
for item in self.check(timeout=timeout):
for item in self.check():
if b"EXISTS" in item or b"RECENT" in item:
return item
@@ -196,10 +192,8 @@ class IdleManager:
"""Return first message with SEEN flag from a running idle-stream."""
while True:
for item in self.check(timeout=timeout):
if FETCH in item:
self.log(str(item))
if FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
if FETCH in item and FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
def done(self):
"""send idle-done to server if we are currently in idle mode."""

View File

@@ -0,0 +1,57 @@
import subprocess
import pytest
from deltachat_rpc_client import DeltaChat, Rpc
def test_install_venv_and_use_other_core(tmp_path, get_core_python_env):
python, rpc_server_path = get_core_python_env("2.24.0")
subprocess.check_call([python, "-m", "pip", "install", "deltachat-rpc-server==2.24.0"])
rpc = Rpc(accounts_dir=tmp_path.joinpath("accounts"), rpc_server_path=rpc_server_path)
with rpc:
dc = DeltaChat(rpc)
assert dc.rpc.get_system_info()["deltachat_core_version"] == "v2.24.0"
@pytest.mark.parametrize("version", ["2.24.0"])
def test_qr_setup_contact(alice_and_remote_bob, version) -> None:
"""Test other-core Bob profile can do securejoin with Alice on current core."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
qr_code = alice.get_qr_code()
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
remote_eval("bob.wait_for_securejoin_joiner_success()")
# Test that Bob verified Alice's profile.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
def test_send_and_receive_message(alice_and_remote_bob) -> None:
"""Test other-core Bob profile can send a message to Alice on current core."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
remote_eval("bob_contact_alice.create_chat().send_text('hello')")
msg = alice.wait_for_incoming_msg()
assert msg.get_snapshot().text == "hello"
def test_second_device(acfactory, alice_and_remote_bob) -> None:
"""Test setting up current version as a second device for old version."""
_alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
remote_eval("locals().setdefault('future', bob._rpc.provide_backup.future(bob.id))")
qr = remote_eval("bob._rpc.get_backup_qr(bob.id)")
new_account = acfactory.get_unconfigured_account()
new_account._rpc.get_backup(new_account.id, qr)
remote_eval("locals()['future']()")
assert new_account.get_config("addr") == remote_eval("bob.get_config('addr')")

View File

@@ -0,0 +1,538 @@
import logging
import re
import time
import pytest
from imap_tools import AND, U
from deltachat_rpc_client import Contact, EventType, Message
def test_move_works(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
# Message is downloaded
msg = ac2.wait_for_incoming_msg().get_snapshot()
assert msg.text == "message1"
def test_move_avoids_loop(acfactory, direct_imap):
"""Test that the message is only moved from INBOX to DeltaChat.
This is to avoid busy loop if moved message reappears in the Inbox
or some scanned folder later.
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.bring_online()
# Create INBOX.DeltaChat folder and make sure
# it is detected by full folder scan.
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("INBOX.DeltaChat")
ac2.stop_io()
ac2.start_io()
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
ac1_chat.send_text("Message 1")
# Message is moved to the DeltaChat folder and downloaded.
ac2_msg1 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX.DeltaChat again.
# We assume that test server uses "." as the delimiter.
ac2_direct_imap.select_folder("DeltaChat")
ac2_direct_imap.conn.move(["*"], "INBOX.DeltaChat")
ac1_chat.send_text("Message 2")
ac2_msg2 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg2.text == "Message 2"
# Stop and start I/O to trigger folder scan.
ac2.stop_io()
ac2.start_io()
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
# Check that Message 1 is still in the INBOX.DeltaChat folder
# and Message 2 is in the DeltaChat folder.
ac2_direct_imap.select_folder("INBOX")
assert len(ac2_direct_imap.get_all_messages()) == 0
ac2_direct_imap.select_folder("DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
ac2_direct_imap.select_folder("INBOX.DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
def test_dont_show_emails(acfactory, direct_imap, log):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
Also, test that unknown emails in the Spam folder are not shown."""
ac1 = acfactory.new_configured_account()
ac1.stop_io()
ac1.set_config("show_emails", "2")
ac1.create_contact("alice@example.org").create_chat()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Drafts")
ac1_direct_imap.create_folder("Spam")
ac1_direct_imap.create_folder("Junk")
# Learn UID validity for all folders.
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
ac1.stop_io()
ac1_direct_imap.append(
"Drafts",
"""
From: ac1 <{}>
Subject: subj
To: alice@example.org
Message-ID: <aepiors@example.org>
Content-Type: text/plain; charset=utf-8
message in Drafts received later
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org, unkwnown.add@junk.org
Subject: subj
To: {}
Message-ID: <spam.message2@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: delta<address: inbox@nhroy.com>
Subject: subj
To: {}
Message-ID: <spam.message99@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: alice@example.org
Subject: subj
To: {}
Message-ID: <spam.message3@junk.org>
Content-Type: text/plain; charset=utf-8
Actually interesting message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Junk",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Junk
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.set_config("scan_all_folders_debounce_secs", "0")
log.section("All prepared, now let DC find the message")
ac1.start_io()
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
fresh_msgs = list(ac1.get_fresh_messages())
msg = fresh_msgs[0].get_snapshot()
chat_msgs = msg.chat.get_messages()
assert len(chat_msgs) == 1
assert msg.text == "subj Actually interesting message in Spam"
assert not any("unknown.address" in c.get_full_snapshot().name for c in ac1.get_chatlist())
ac1_direct_imap.select_folder("Spam")
assert ac1_direct_imap.get_uid_by_message_id("spam.message@junk.org")
ac1.stop_io()
log.section("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
ac1_direct_imap.select_folder("Drafts")
uid = ac1_direct_imap.get_uid_by_message_id("aepiors@example.org")
ac1_direct_imap.conn.move(uid, "Inbox")
ac1.start_io()
event = ac1.wait_for_event(EventType.MSGS_CHANGED)
msg2 = Message(ac1, event.msg_id).get_snapshot()
assert msg2.text == "subj message in Drafts received later"
assert len(msg.chat.get_messages()) == 2
def test_move_works_on_self_sent(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
# Enable movebox and wait until it is created.
ac1.set_config("mvbox_move", "1")
ac1.set_config("bcc_self", "1")
ac1.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message2")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message3")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
def test_moved_markseen(acfactory, direct_imap):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
ac2.bring_online()
ac2.stop_io()
ac2_direct_imap = direct_imap(ac2)
with ac2_direct_imap.idle() as idle2:
ac1.create_chat(ac2).send_text("Hello!")
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
ac2_direct_imap.conn.move(["*"], "DeltaChat")
ac2_direct_imap.select_folder("DeltaChat")
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
with ac2_direct_imap.idle() as idle2:
ac2.start_io()
ev = ac2.wait_for_event(EventType.MSGS_CHANGED)
msg = ac2.get_message_by_id(ev.msg_id)
assert msg.get_snapshot().text == "Messages are end-to-end encrypted."
ev = ac2.wait_for_event(EventType.INCOMING_MSG)
msg = ac2.get_message_by_id(ev.msg_id)
chat = ac2.get_chat_by_id(ev.chat_id)
# Accept the contact request.
chat.accept()
msg.mark_seen()
idle2.wait_for_seen()
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
ac1, ac2 = acfactory.get_online_accounts(2)
for ac in ac1, ac2:
ac.set_config("delete_server_after", "0")
if mvbox_move:
ac.set_config("mvbox_move", "1")
ac.bring_online()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2.wait_for_incoming_msg()
msg.mark_seen()
if mvbox_move:
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
for ac in ac1, ac2:
while True:
event = ac.wait_for_event()
if event.kind == EventType.INFO and rex.search(event.msg):
break
folder = "mvbox" if mvbox_move else "inbox"
ac1_direct_imap = direct_imap(ac1)
ac2_direct_imap = direct_imap(ac2)
ac1_direct_imap.select_config_folder(folder)
ac2_direct_imap.select_config_folder(folder)
# Check that the mdn is marked as seen
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
# Check original message is marked as seen
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
def test_mvbox_and_trash(acfactory, direct_imap, log):
log.section("ac1: start with mvbox")
ac1 = acfactory.get_online_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
log.section("ac2: start without a mvbox")
ac2 = acfactory.get_online_account()
log.section("ac1: create trash")
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Trash")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.stop_io()
ac1.start_io()
log.section("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2.wait_for_incoming_msg().get_snapshot().text == "message1"
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
while ac1.get_config("configured_trash_folder") != "Trash":
ac1.wait_for_event(EventType.CONNECTIVITY_CHANGED)
@pytest.mark.parametrize(
("folder", "move", "expected_destination"),
[
(
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
(
"xyz",
True,
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,
"INBOX",
), # ...emails are moved from the spam folder to the Inbox
],
)
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
def test_scan_folders(acfactory, log, direct_imap, folder, move, expected_destination):
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
variant = folder + "-" + str(move) + "-" + expected_destination
log.section("Testing variant " + variant)
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("delete_server_after", "0")
if move:
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1.stop_io()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder(folder)
# Wait until each folder was selected once and we are IDLEing:
ac1.start_io()
ac1.bring_online()
ac1.stop_io()
assert folder in ac1_direct_imap.list_folders()
log.section("Send a message from ac2 to ac1 and manually move it to `folder`")
ac1_direct_imap.select_config_folder("inbox")
with ac1_direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
idle1.wait_for_new_message()
ac1_direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
log.section("start_io() and see if DeltaChat finds the message (" + variant + ")")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
chat = ac1.create_chat(ac2)
n_msgs = 1 # "Messages are end-to-end encrypted."
if folder == "Spam":
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
n_msgs += 1
else:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
assert len(chat.get_messages()) == n_msgs
# The message has reached its destination.
ac1_direct_imap.select_folder(expected_destination)
assert len(ac1_direct_imap.get_all_messages()) == 1
if folder != expected_destination:
ac1_direct_imap.select_folder(folder)
assert len(ac1_direct_imap.get_all_messages()) == 0
def test_trash_multiple_messages(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
log.section("Creating trash folder")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("Trash")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0")
ac2.set_config("delete_to_trash", "1")
log.section("Check that Trash can be configured initially as well")
ac3 = ac2.clone()
ac3.bring_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acfactory.get_accepted_chat(ac1, ac2)
log.section("ac1: sending 3 messages")
texts = ["first", "second", "third"]
for text in texts:
chat12.send_text(text)
log.section("ac2: waiting for all messages on the other side")
to_delete = []
for text in texts:
msg = ac2.wait_for_incoming_msg().get_snapshot()
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
log.section("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1
ac2.delete_messages(to_delete)
log.section("ac2: test that only one message is left")
while 1:
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
ac2_direct_imap.select_config_folder("inbox")
nr_msgs = len(ac2_direct_imap.get_all_messages())
assert nr_msgs > 0
if nr_msgs == 1:
break

View File

@@ -84,7 +84,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
# share a webxdc app between ac1 and ac2
ac1_webxdc_msg = acfactory.send_message(from_account=ac1, to_account=ac2, text="play", file=path_to_webxdc)
ac2_webxdc_msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id)
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
snapshot = ac2_webxdc_msg.get_snapshot()
assert snapshot.text == "play"
@@ -94,7 +94,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
acfactory.send_message(from_account=ac1, to_account=ac2, text="ping1")
log("waiting for incoming message on ac2")
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "ping1"
log("sending ac2 -> ac1 realtime advertisement and additional message")
@@ -102,7 +102,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
acfactory.send_message(from_account=ac2, to_account=ac1, text="ping2")
log("waiting for incoming message on ac1")
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "ping2"
log("sending realtime data ac1 -> ac2")

View File

@@ -4,6 +4,41 @@ from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import MessageState
def test_bcc_self_delete_server_after_defaults(acfactory):
"""Test default values for bcc_self and delete_server_after."""
ac = acfactory.get_online_account()
# Initially after getting online
# the setting bcc_self is set to 0 because there is only one device
# and delete_server_after is "1", meaning immediate deletion.
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Setup a second device.
ac_clone = ac.clone()
ac_clone.bring_online()
# Second device setup
# enables bcc_self and changes default delete_server_after.
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
assert ac_clone.get_config("bcc_self") == "1"
assert ac_clone.get_config("delete_server_after") == "0"
# Manually disabling bcc_self
# also restores the default for delete_server_after.
ac.set_config("bcc_self", "0")
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Cloning the account again enables bcc_self
# even though it was manually disabled.
ac_clone = ac.clone()
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()

View File

@@ -0,0 +1,278 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.rpc import JsonRpcError
def test_add_second_address(acfactory) -> None:
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
# When the first transport is created,
# mvbox_move and only_fetch_mvbox should be disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 2
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 3
first_addr = account.list_transports()[0]["addr"]
second_addr = account.list_transports()[1]["addr"]
# Cannot delete the first address.
with pytest.raises(JsonRpcError):
account.delete_transport(first_addr)
account.delete_transport(second_addr)
assert len(account.list_transports()) == 2
# Enabling mvbox_move or only_fetch_mvbox
# is not allowed when multi-transport is enabled.
for option in ["mvbox_move", "only_fetch_mvbox"]:
with pytest.raises(JsonRpcError):
account.set_config(option, "1")
with pytest.raises(JsonRpcError):
account.set_config("show_emails", "0")
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
def test_no_second_transport_with_mvbox(acfactory, key) -> None:
"""Test that second transport cannot be configured if mvbox is used."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
qr = acfactory.get_account_qr()
account.set_config(key, "1")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_no_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport cannot be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.set_config("show_emails", "0")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_change_address(acfactory) -> None:
"""Test Alice configuring a second transport and setting it as a primary one."""
alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr")
bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello!")
msg1 = bob.wait_for_incoming_msg().get_snapshot()
sender_addr1 = msg1.sender.get_snapshot().address
alice.stop_io()
old_alice_addr = alice.get_config("configured_addr")
alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr in alice_vcard
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"]
with pytest.raises(JsonRpcError):
# Cannot use the address that is not
# configured for any transport.
alice.set_config("configured_addr", bob_addr)
# Load old address so it is cached.
assert alice.get_config("configured_addr") == old_alice_addr
alice.set_config("configured_addr", new_alice_addr)
# Make sure that setting `configured_addr` invalidated the cache.
assert alice.get_config("configured_addr") == new_alice_addr
alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr not in alice_vcard
assert new_alice_addr in alice_vcard
with pytest.raises(JsonRpcError):
alice.delete_transport(new_alice_addr)
alice.start_io()
alice_chat_bob.send_text("Hello again!")
msg2 = bob.wait_for_incoming_msg().get_snapshot()
sender_addr2 = msg2.sender.get_snapshot().address
assert msg1.sender == msg2.sender
assert sender_addr1 != sender_addr2
assert sender_addr1 == old_alice_addr
assert sender_addr2 == new_alice_addr
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
Disabling mvbox_move is required to be able to setup a second transport.
"""
account = acfactory.get_unconfigured_account()
account.set_config("fix_is_chatmail", "1")
account.set_config("is_chatmail", is_chatmail)
# The default value when the setting is unset is "1".
# This is not changed for compatibility with old databases
# imported from backups.
assert account.get_config("mvbox_move") == "1"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
# Once the first transport is set up,
# mvbox_move is disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("is_chatmail") == is_chatmail
def test_reconfigure_transport(acfactory) -> None:
"""Test that reconfiguring the transport works
even if settings not supported for multi-transport
like mvbox_move are enabled."""
account = acfactory.get_online_account()
account.set_config("mvbox_move", "1")
[transport] = account.list_transports()
account.add_or_update_transport(transport)
# Reconfiguring the transport should not reset
# the settings as if when configuring the first transport.
assert account.get_config("mvbox_move") == "1"
def test_transport_synchronization(acfactory, log) -> None:
"""Test synchronization of transports between devices."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
qr = acfactory.get_account_qr()
ac1.add_transport_from_qr(qr)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert len(ac1.list_transports()) == 2
assert len(ac1_clone.list_transports()) == 2
ac1_clone.add_transport_from_qr(qr)
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert len(ac1.list_transports()) == 3
assert len(ac1_clone.list_transports()) == 3
log.section("ac1 clone removes second transport")
[transport1, transport2, transport3] = ac1_clone.list_transports()
addr3 = transport3["addr"]
ac1_clone.delete_transport(transport2["addr"])
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
[transport1, transport3] = ac1.list_transports()
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport3["addr"])
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
[transport1, transport3] = ac1_clone.list_transports()
assert ac1_clone.get_config("configured_addr") == addr3
log.section("ac1 removes the first transport")
ac1.delete_transport(transport1["addr"])
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
[transport3] = ac1_clone.list_transports()
assert transport3["addr"] == addr3
assert ac1_clone.get_config("configured_addr") == addr3
ac2_chat = ac2.create_chat(ac1)
ac2_chat.send_text("Hello!")
assert ac1.wait_for_incoming_msg().get_snapshot().text == "Hello!"
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "Hello!"
def test_recognize_self_address(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
bob_chat = bob.create_chat(alice)
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"]
alice.set_config("configured_addr", new_alice_addr)
bob_chat.send_text("Hello!")
msg = alice.wait_for_incoming_msg().get_snapshot()
assert msg.chat == alice.create_chat(bob)
def test_transport_limit(acfactory) -> None:
"""Test transports limit."""
account = acfactory.get_online_account()
qr = acfactory.get_account_qr()
limit = 5
for _ in range(1, limit):
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == limit
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
second_addr = account.list_transports()[1]["addr"]
account.delete_transport(second_addr)
# test that adding a transport after deleting one works again
account.add_transport_from_qr(qr)
def test_message_info_imap_urls(acfactory, log) -> None:
"""Test that message info contains IMAP URLs of where the message was received."""
alice, bob = acfactory.get_online_accounts(2)
log.section("Alice adds ac1 clone removes second transport")
qr = acfactory.get_account_qr()
for i 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()
new_alice_addr = alice.list_transports()[2]["addr"]
alice.set_config("configured_addr", new_alice_addr)
# Enable multi-device mode so messages are not deleted immediately.
alice.set_config("bcc_self", "1")
# Bob creates chat, learning about Alice's currently selected transport.
# This is where he will send the message.
bob_chat = bob.create_chat(alice)
# Alice changes the transport again.
alice.set_config("configured_addr", alice.list_transports()[3]["addr"])
bob_chat.send_text("Hello!")
msg = alice.wait_for_incoming_msg()
for alice_transport in alice.list_transports():
addr = alice_transport["addr"]
assert (addr == new_alice_addr) == (addr in msg.get_info())

View File

@@ -86,7 +86,7 @@ def test_qr_securejoin(acfactory):
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
# Test that Bob verified Alice's profile.
@@ -140,15 +140,15 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
return chat
def wait_for_broadcast_messages(ac):
snapshot1 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot1.text == "You joined the channel."
snapshot2 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot2.text == "Hello everyone!"
chat = get_broadcast(ac)
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You joined the channel."
assert snapshot.chat_id == chat.id
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!"
assert snapshot.chat_id == chat.id
assert snapshot1.chat_id == chat.id
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.
@@ -158,29 +158,29 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
chat = get_broadcast(ac)
chat_msgs = chat.get_messages()
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…"
assert first_msg.is_info
encrypted_msg = chat_msgs[0].get_snapshot()
encrypted_msg = chat_msgs.pop(0).get_snapshot()
assert encrypted_msg.text == "Messages are end-to-end encrypted."
assert encrypted_msg.is_info
member_added_msg = chat_msgs[1].get_snapshot()
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
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
hello_msg = chat_msgs[2].get_snapshot()
hello_msg = chat_msgs.pop(0).get_snapshot()
assert hello_msg.text == "Hello everyone!"
assert not hello_msg.is_info
assert hello_msg.show_padlock
assert hello_msg.error is None
assert len(chat_msgs) == 3
assert len(chat_msgs) == 0
chat_snapshot = chat.get_full_snapshot()
assert chat_snapshot.is_encrypted
@@ -255,7 +255,7 @@ def test_qr_securejoin_contact_request(acfactory) -> None:
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("Hello!")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello!"
bob_chat_alice = snapshot.chat
assert bob_chat_alice.get_basic_snapshot().is_contact_request
@@ -299,8 +299,7 @@ def test_qr_readreceipt(acfactory) -> None:
logging.info("Bob and Charlie receive a group")
bob_msg_id = bob.wait_for_incoming_msg_event().msg_id
bob_message = bob.get_message_by_id(bob_msg_id)
bob_message = bob.wait_for_incoming_msg()
bob_snapshot = bob_message.get_snapshot()
assert bob_snapshot.text == "Hello"
@@ -311,8 +310,7 @@ def test_qr_readreceipt(acfactory) -> None:
bob_out_message = bob_snapshot.chat.send_message(text="Hi from Bob!")
charlie_msg_id = charlie.wait_for_incoming_msg_event().msg_id
charlie_message = charlie.get_message_by_id(charlie_msg_id)
charlie_message = charlie.wait_for_incoming_msg()
charlie_snapshot = charlie_message.get_snapshot()
assert charlie_snapshot.text == "Hi from Bob!"
@@ -387,7 +385,7 @@ def test_verified_group_member_added_recovery(acfactory) -> None:
ac3_contact_ac2 = ac3.create_contact(ac2)
ac3_chat.remove_contact(ac3_contact_ac2_old)
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert "removed" in snapshot.text
ac3_chat.add_contact(ac3_contact_ac2)
@@ -400,18 +398,17 @@ def test_verified_group_member_added_recovery(acfactory) -> None:
logging.info("ac2 got event message: %s", snapshot.text)
assert "added" in snapshot.text
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert "added" in snapshot.text
chat = Chat(ac2, chat_id)
chat.send_text("Works again!")
msg_id = ac3.wait_for_incoming_msg_event().msg_id
message = ac3.get_message_by_id(msg_id)
message = ac3.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.text == "Works again!"
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Works again!"
ac1_contact_ac2 = ac1.create_contact(ac2)
@@ -447,7 +444,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
# ensure ac1 can write and ac2 receives messages in verified chat
ch1.send_text("ac1 says hello")
while 1:
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
if snapshot.text == "ac1 says hello":
break
@@ -468,7 +465,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
# ensure ac2 receives message in VG
vg.send_text("hello")
while 1:
msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
msg = ac2.wait_for_incoming_msg().get_snapshot()
if msg.text == "hello":
break
@@ -505,7 +502,7 @@ def test_qr_new_group_unblocked(acfactory):
ac2.wait_for_incoming_msg_event()
ac1_new_chat.send_text("Hello!")
ac2_msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
ac2_msg = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg.text == "Hello!"
assert ac2_msg.chat.get_basic_snapshot().is_contact_request
@@ -530,7 +527,7 @@ def test_aeap_flow_verified(acfactory):
logging.info("receiving first message")
ac2.wait_for_incoming_msg_event() # member added message
msg_in_1 = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
msg_in_1 = ac2.wait_for_incoming_msg().get_snapshot()
assert msg_in_1.text == msg_out.text
logging.info("changing email account")
@@ -544,7 +541,7 @@ def test_aeap_flow_verified(acfactory):
msg_out = chat.send_text("changed address").get_snapshot()
logging.info("receiving second message")
msg_in_2 = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id)
msg_in_2 = ac2.wait_for_incoming_msg()
msg_in_2_snapshot = msg_in_2.get_snapshot()
assert msg_in_2_snapshot.text == msg_out.text
assert msg_in_2_snapshot.chat.id == msg_in_1.chat.id
@@ -576,7 +573,7 @@ def test_gossip_verification(acfactory) -> None:
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Autocrypt group")
snapshot = carol.get_message_by_id(carol.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Autocrypt group"
assert snapshot.show_padlock
@@ -592,7 +589,7 @@ def test_gossip_verification(acfactory) -> None:
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Securejoin group")
snapshot = carol.get_message_by_id(carol.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Securejoin group"
assert snapshot.show_padlock
@@ -620,7 +617,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
ac1.wait_for_securejoin_joiner_success()
# ac1 waits for member added message and creates a QR code.
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(ac3.get_config("addr"))
ac1_qr_code = snapshot.chat.get_qr_code()
@@ -657,7 +654,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
# Wait for member added.
logging.info("ac2 waits for member added message")
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
assert snapshot.is_info
ac2_chat = snapshot.chat
assert len(ac2_chat.get_contacts()) == 3
@@ -679,7 +676,7 @@ def test_withdraw_securejoin_qr(acfactory):
alice.clear_all_events()
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
bob_chat.leave()

View File

@@ -90,12 +90,9 @@ def test_lowercase_address(acfactory) -> None:
assert account.get_config("configured_addr") == addr
assert account.list_transports()[0]["addr"] == addr
for param in [
account.get_info()["used_account_settings"],
account.get_info()["entered_account_settings"],
]:
assert addr in param
assert addr_upper not in param
param = account.get_info()["used_transport_settings"]
assert addr in param
assert addr_upper not in param
def test_configure_ip(acfactory) -> None:
@@ -343,18 +340,18 @@ def test_receive_imf_failure(acfactory) -> None:
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
version = bob.get_info()["deltachat_core_version"]
assert (
snapshot.text == "❌ Failed to receive a message:"
" Condition failed: `!context.get_config_bool(Config::FailOnReceivingFullMsg).await?`."
f" Core version {version}."
" Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
)
# The failed message doesn't break the IMAP loop.
bob.set_config("fail_on_receiving_full_msg", "0")
alice_chat_bob.send_text("Hello again!")
event = bob.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
message = bob.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.text == "Hello again!"
assert snapshot.download_state == DownloadState.DONE
@@ -423,10 +420,7 @@ def test_is_bot(acfactory) -> None:
alice.set_config("bot", "1")
alice_chat_bob.send_text("Hello!")
event = bob.wait_for_incoming_msg_event()
message = bob.get_message_by_id(event.msg_id)
snapshot = message.get_snapshot()
assert snapshot.chat_id == event.chat_id
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello!"
assert snapshot.is_bot
@@ -472,7 +466,7 @@ def test_bot(acfactory) -> None:
def test_wait_next_messages(acfactory) -> None:
alice = acfactory.new_configured_account()
alice = acfactory.get_online_account()
# Create a bot account so it does not receive device messages in the beginning.
addr, password = acfactory.get_credentials()
@@ -480,6 +474,7 @@ def test_wait_next_messages(acfactory) -> None:
bot.set_config("bot", "1")
bot.add_or_update_transport({"addr": addr, "password": password})
assert bot.is_configured()
bot.bring_online()
# There are no old messages and the call returns immediately.
assert not bot.wait_next_messages()
@@ -518,7 +513,7 @@ def test_import_export_keys(acfactory, tmp_path) -> None:
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello Bob!")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Bob!"
# Alice resetups account, but keeps the key.
@@ -530,7 +525,7 @@ def test_import_export_keys(acfactory, tmp_path) -> None:
snapshot.chat.accept()
snapshot.chat.send_text("Hello Alice!")
snapshot = alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = alice.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Alice!"
assert snapshot.show_padlock
@@ -575,18 +570,13 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
# Alice sends a message to Bob.
alice_chat_bob.send_text("Hello Bob!")
event = bob.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
# Bob sends a message to Alice.
bob_chat_alice = snapshot.chat
bob_chat_alice.accept()
bob_chat_alice.send_text("Hello Alice!")
event = alice.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = alice.get_message_by_id(msg_id)
message = alice.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.show_padlock
@@ -596,10 +586,7 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
# Bob sends a message to Alice, it should also be encrypted.
bob_chat_alice.send_text("Hi Alice!")
event = alice.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = alice.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
snapshot = alice.wait_for_incoming_msg().get_snapshot()
assert snapshot.show_padlock
@@ -657,50 +644,6 @@ def test_reaction_to_partially_fetched_msg(acfactory, tmp_path):
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
@pytest.mark.parametrize("n_accounts", [3, 2])
def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
download_limit = 300000
@@ -712,17 +655,15 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
for account in others:
chat = account.create_chat(alice)
chat.send_text("Hello Alice!")
assert alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot().text == "Hello Alice!"
assert alice.wait_for_incoming_msg().get_snapshot().text == "Hello Alice!"
contact = alice.create_contact(account)
alice_group.add_contact(contact)
if n_accounts == 2:
bob_chat_alice = bob.create_chat(alice)
bob.set_config("download_limit", str(download_limit))
alice_group.send_text("hi")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "hi"
bob_group = snapshot.chat
@@ -732,17 +673,9 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
for i in range(10):
logging.info("Sending message %s", i)
alice_group.send_file(str(path))
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
if n_accounts > 2:
assert snapshot.chat == bob_group
else:
# Group contains only Alice and Bob,
# so partially downloaded messages are
# hard to distinguish from private replies to group messages.
#
# Message may be a private reply, so we assign it to 1:1 chat with Alice.
assert snapshot.chat == bob_chat_alice
assert snapshot.chat == bob_group
def test_markseen_contact_request(acfactory):
@@ -759,8 +692,8 @@ def test_markseen_contact_request(acfactory):
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello Bob!")
message = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id)
message2 = bob2.get_message_by_id(bob2.wait_for_incoming_msg_event().msg_id)
message = bob.wait_for_incoming_msg()
message2 = bob2.wait_for_incoming_msg()
assert message2.get_snapshot().state == MessageState.IN_FRESH
message.mark_seen()
@@ -782,7 +715,7 @@ def test_read_receipt(acfactory):
msg = bob.wait_for_incoming_msg()
msg.mark_seen()
read_msg = alice.get_message_by_id(alice.wait_for_event(EventType.MSG_READ).msg_id)
read_msg = alice.wait_for_msg(EventType.MSG_READ)
read_receipts = read_msg.get_read_receipts()
assert len(read_receipts) == 1
assert read_receipts[0].contact_id == alice_contact_bob.id
@@ -799,7 +732,7 @@ def test_configured_imap_certificate_checks(acfactory):
alice = acfactory.new_configured_account()
# Certificate checks should be configured (not None)
assert "cert_strict" in alice.get_info().used_account_settings
assert "cert_strict" in alice.get_info().used_transport_settings
# "cert_old_automatic" is the value old Delta Chat core versions used
# to mean user entered "imap_certificate_checks=0" (Automatic)
@@ -812,7 +745,7 @@ def test_configured_imap_certificate_checks(acfactory):
#
# Core 1.142.4, 1.142.5 and 1.142.6 saved this value due to bug.
# This test is a regression test to prevent this happening again.
assert "cert_old_automatic" not in alice.get_info().used_account_settings
assert "cert_old_automatic" not in alice.get_info().used_transport_settings
def test_no_old_msg_is_fresh(acfactory):
@@ -888,30 +821,6 @@ def test_get_all_accounts_deadlock(rpc):
all_accounts()
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_leave_broadcast(acfactory, all_devices_online):
alice, bob = acfactory.get_online_accounts(2)
@@ -948,15 +857,15 @@ def test_leave_broadcast(acfactory, all_devices_online):
contact_snapshot = contact.get_snapshot()
chat_msgs = chat.get_messages()
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…"
assert first_msg.is_info
encrypted_msg = chat_msgs.pop(0).get_snapshot()
assert encrypted_msg.text == "Messages are end-to-end encrypted."
assert encrypted_msg.is_info
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
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."
@@ -1012,3 +921,137 @@ def test_leave_broadcast(acfactory, all_devices_online):
bob2.wait_for_event(EventType.CHAT_MODIFIED)
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
def test_immediate_autodelete(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
log.section("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)
ac2.create_chat(ac1)
log.section("ac1: send message to ac2")
sent_msg = chat1.send_text("hello")
msg = ac2.wait_for_incoming_msg()
assert msg.get_snapshot().text == "hello"
log.section("ac2: wait for close/expunge on autodelete")
ac2.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
while True:
event = ac2.wait_for_event()
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
break
log.section("ac2: check that message was autodeleted on server")
ac2_direct_imap = direct_imap(ac2)
assert len(ac2_direct_imap.get_all_messages()) == 0
log.section("ac2: Mark deleted message as seen and check that read receipt arrives")
msg.mark_seen()
ev = ac1.wait_for_event(EventType.MSG_READ)
assert ev.chat_id == chat1.id
assert ev.msg_id == sent_msg.id
def test_background_fetch(acfactory, dc):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.stop_io()
ac1_chat = ac1.create_chat(ac2)
ac2_chat = ac2.create_chat(ac1)
ac2_chat.send_text("Hello!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello!":
break
# Stopping background fetch immediately after starting
# does not result in any errors.
background_fetch_future = dc.background_fetch.future(300)
dc.stop_background_fetch()
background_fetch_future()
# Starting background fetch with zero timeout is ok,
# it should terminate immediately.
dc.background_fetch(0)
# Background fetch can still be used to send and receive messages.
ac2_chat.send_text("Hello again!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello again!":
break
def test_message_exists(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = ac1.create_chat(ac2)
message1 = chat.send_text("Hello!")
message2 = chat.send_text("Hello again!")
assert message1.exists()
assert message2.exists()
ac1.delete_messages([message1])
assert not message1.exists()
assert message2.exists()
# There is no error when checking if
# the message exists for deleted account.
ac1.remove()
assert not message1.exists()
assert not message2.exists()
def test_synchronize_member_list_on_group_rejoin(acfactory, log):
"""
Test that user recreates group member list when it joins the group again.
ac1 creates a group with two other accounts: ac2 and ac3
Then it removes ac2, removes ac3 and adds ac2 back.
ac2 did not see that ac3 is removed, so it should rebuild member list from scratch.
"""
log.section("setting up accounts, accepted with each other")
ac1, ac2, ac3 = accounts = acfactory.get_online_accounts(3)
log.section("ac1: creating group chat with 2 other members")
chat = ac1.create_group("title1")
chat.add_contact(ac2)
chat.add_contact(ac3)
log.section("ac1: send message to new group chat")
msg = chat.send_text("hello")
assert chat.num_contacts() == 3
log.section("checking that the chat arrived correctly")
for ac in accounts[1:]:
msg = ac.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert msg.chat.num_contacts() == 3
msg.chat.accept()
log.section("ac1: removing ac2")
chat.remove_contact(ac2)
log.section("ac2: wait for a message about removal from the chat")
ac2.wait_for_incoming_msg()
log.section("ac1: removing ac3")
chat.remove_contact(ac3)
log.section("ac1: adding ac2 back")
chat.add_contact(ac2)
log.section("ac2: check that ac3 is removed")
msg = ac2.wait_for_incoming_msg()
assert chat.num_contacts() == 2
assert msg.get_snapshot().chat.num_contacts() == 2

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.25.0"
version = "2.35.0"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.25.0"
"version": "2.35.0"
}

View File

@@ -43,7 +43,7 @@ async fn main_impl() -> Result<()> {
if let Some(arg) = args.next() {
return Err(anyhow!("Unrecognized argument {arg:?}"));
}
eprintln!("{}", &*DC_VERSION_STR);
eprintln!("{DC_VERSION_STR}");
return Ok(());
} else if first_arg.to_str() == Some("--openrpc") {
if let Some(arg) = args.next() {

View File

@@ -12,6 +12,11 @@ ignore = [
# Unmaintained paste
"RUSTSEC-2024-0436",
# Unmaintained rustls-pemfile
# It is a transitive dependency of iroh 0.35.0,
# this should be fixed by upgrading to iroh 1.0 once it is released.
"RUSTSEC-2025-0134"
]
[bans]
@@ -38,6 +43,7 @@ skip = [
{ name = "rand", version = "0.8.5" },
{ name = "rustix", version = "0.38.44" },
{ name = "serdect", version = "0.2.0" },
{ name = "socket2", version = "0.5.9" },
{ name = "spin", version = "0.9.8" },
{ name = "strum_macros", version = "0.26.2" },
{ name = "strum", version = "0.26.2" },
@@ -62,7 +68,6 @@ skip = [
{ name = "windows_x86_64_gnu" },
{ name = "windows_x86_64_gnullvm" },
{ name = "windows_x86_64_msvc" },
{ name = "zerocopy", version = "0.7.32" },
]

18
flake.lock generated
View File

@@ -47,11 +47,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1747291057,
"narHash": "sha256-9Wir6aLJAeJKqdoQUiwfKdBn7SyNXTJGRSscRyVOo2Y=",
"lastModified": 1763361733,
"narHash": "sha256-ka7dpwH3HIXCyD2wl5F7cPLeRbqZoY2ullALsvxdPt8=",
"owner": "nix-community",
"repo": "fenix",
"rev": "76ffc1b7b3ec8078fe01794628b6abff35cbda8f",
"rev": "6c8d48e3b0ae371b19ac1485744687b788e80193",
"type": "github"
},
"original": {
@@ -147,11 +147,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1747179050,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"lastModified": 1762977756,
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
"type": "github"
},
"original": {
@@ -202,11 +202,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1746889290,
"narHash": "sha256-h3LQYZgyv2l3U7r+mcsrEOGRldaK0zJFwAAva4hV/6g=",
"lastModified": 1762860488,
"narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "2bafe9d96c6734aacfd49e115f6cf61e7adc68bc",
"rev": "2efc80078029894eec0699f62ec8d5c1a56af763",
"type": "github"
},
"original": {

View File

@@ -1,5 +1,5 @@
{
description = "Delta Chat core";
description = "Chatmail core";
inputs = {
fenix.url = "github:nix-community/fenix";
flake-utils.url = "github:numtide/flake-utils";
@@ -14,7 +14,15 @@
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs.stdenv) isDarwin;
fenixPkgs = fenix.packages.${system};
naersk' = pkgs.callPackage naersk { };
fenixToolchain = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.stable.rust-std
];
naersk' = pkgs.callPackage naersk {
cargo = fenixToolchain;
rustc = fenixToolchain;
};
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
androidSdk = android.sdk.${system} (sdkPkgs:
builtins.attrValues {
@@ -34,7 +42,6 @@
./Cargo.lock
./Cargo.toml
./CMakeLists.txt
./CONTRIBUTING.md
./deltachat_derive
./deltachat-contact-tools
./deltachat-ffi
@@ -471,6 +478,12 @@
};
libdeltachat =
let
rustPlatform = (pkgs.makeRustPlatform {
cargo = fenixToolchain;
rustc = fenixToolchain;
});
in
pkgs.stdenv.mkDerivation {
pname = "libdeltachat";
version = manifest.version;
@@ -480,8 +493,9 @@
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
pkgs.cmake
pkgs.rustPlatform.cargoSetupHook
pkgs.cargo
rustPlatform.cargoSetupHook
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
];
postInstall = ''

View File

@@ -14,6 +14,7 @@ def datadir():
return None
@pytest.mark.skip("The test is flaky in CI and crashes the interpreter as of 2025-11-12")
def test_echo_quit_plugin(acfactory, lp):
lp.sec("creating one echo_and_quit bot")
botproc = acfactory.run_bot_process(echo_and_quit)

View File

@@ -1,20 +1,20 @@
[build-system]
requires = ["setuptools>=45", "wheel", "cffi>=1.0.0", "pkgconfig"]
requires = ["setuptools>=77", "wheel", "cffi>=1.0.0", "pkgconfig"]
build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.25.0"
version = "2.35.0"
license = "MPL-2.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
requires-python = ">=3.8"
requires-python = ">=3.10"
authors = [
{ name = "holger krekel, Floris Bruynooghe, Bjoern Petersen and contributors" },
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
"Programming Language :: Python :: 3",
"Topic :: Communications :: Chat",
"Topic :: Communications :: Email",
@@ -23,7 +23,6 @@ classifiers = [
dependencies = [
"cffi>=1.0.0",
"imap-tools",
"importlib_metadata;python_version<'3.8'",
"pluggy",
"requests",
]

View File

@@ -1,4 +1,3 @@
import sys
import time
import deltachat as dc
@@ -63,56 +62,6 @@ class TestGroupStressTests:
# Message should be encrypted because keys of other members are gossiped
assert msg.is_encrypted()
def test_synchronize_member_list_on_group_rejoin(self, acfactory, lp):
"""
Test that user recreates group member list when it joins the group again.
ac1 creates a group with two other accounts: ac2 and ac3
Then it removes ac2, removes ac3 and adds ac2 back.
ac2 did not see that ac3 is removed, so it should rebuild member list from scratch.
"""
lp.sec("setting up accounts, accepted with each other")
accounts = acfactory.get_online_accounts(3)
acfactory.introduce_each_other(accounts)
ac1, ac2, ac3 = accounts
lp.sec("ac1: creating group chat with 2 other members")
chat = ac1.create_group_chat("title1", contacts=[ac2, ac3])
assert not chat.is_promoted()
lp.sec("ac1: send message to new group chat")
msg = chat.send_text("hello")
assert chat.is_promoted() and msg.is_encrypted()
assert chat.num_contacts() == 3
lp.sec("checking that the chat arrived correctly")
for ac in accounts[1:]:
msg = ac._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
print("chat is", msg.chat)
assert msg.chat.num_contacts() == 3
lp.sec("ac1: removing ac2")
chat.remove_contact(ac2)
lp.sec("ac2: wait for a message about removal from the chat")
msg = ac2._evtracker.wait_next_incoming_message()
lp.sec("ac1: removing ac3")
chat.remove_contact(ac3)
lp.sec("ac1: adding ac2 back")
# Group is promoted, message is sent automatically
assert chat.is_promoted()
chat.add_contact(ac2)
lp.sec("ac2: check that ac3 is removed")
msg = ac2._evtracker.wait_next_incoming_message()
assert chat.num_contacts() == 2
assert msg.chat.num_contacts() == 2
acfactory.dump_imap_summary(sys.stdout)
def test_qr_verified_group_and_chatting(acfactory, lp):
ac1, ac2, ac3 = acfactory.get_online_accounts(3)

View File

@@ -5,7 +5,7 @@ import base64
from datetime import datetime, timezone
import pytest
from imap_tools import AND, U
from imap_tools import AND
import deltachat as dc
from deltachat import account_hookimpl, Message
@@ -269,112 +269,6 @@ def test_enable_mvbox_move(acfactory, lp):
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
def test_mvbox_thread_and_trash(acfactory, lp):
lp.sec("ac1: start with mvbox thread")
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
lp.sec("ac2: start without a mvbox thread")
ac2 = acfactory.new_online_configuring_account(mvbox_move=False)
lp.sec("ac2 and ac1: waiting for configuration")
acfactory.bring_accounts_online()
lp.sec("ac1: create trash")
ac1.direct_imap.create_folder("Trash")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.stop_io()
ac1.start_io()
lp.sec("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
while ac1.get_config("configured_trash_folder") != "Trash":
ac1._evtracker.get_matching("DC_EVENT_CONNECTIVITY_CHANGED")
def test_move_works(acfactory):
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
chat = acfactory.get_accepted_chat(ac1, ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
# Message is downloaded
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG")
assert ev.data2 > dc.const.DC_CHAT_ID_LAST_SPECIAL
def test_move_avoids_loop(acfactory):
"""Test that the message is only moved from INBOX to DeltaChat.
This is to avoid busy loop if moved message reappears in the Inbox
or some scanned folder later.
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
# Create INBOX.DeltaChat folder and make sure
# it is detected by full folder scan.
ac2.direct_imap.create_folder("INBOX.DeltaChat")
ac2.stop_io()
ac2.start_io()
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
ac1_chat.send_text("Message 1")
# Message is moved to the DeltaChat folder and downloaded.
ac2_msg1 = ac2._evtracker.wait_next_incoming_message()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX.DeltaChat again.
# We assume that test server uses "." as the delimiter.
ac2.direct_imap.select_folder("DeltaChat")
ac2.direct_imap.conn.move(["*"], "INBOX.DeltaChat")
ac1_chat.send_text("Message 2")
ac2_msg2 = ac2._evtracker.wait_next_incoming_message()
assert ac2_msg2.text == "Message 2"
# Stop and start I/O to trigger folder scan.
ac2.stop_io()
ac2.start_io()
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
# Check that Message 1 is still in the INBOX.DeltaChat folder
# and Message 2 is in the DeltaChat folder.
ac2.direct_imap.select_folder("INBOX")
assert len(ac2.direct_imap.get_all_messages()) == 0
ac2.direct_imap.select_folder("DeltaChat")
assert len(ac2.direct_imap.get_all_messages()) == 1
ac2.direct_imap.select_folder("INBOX.DeltaChat")
assert len(ac2.direct_imap.get_all_messages()) == 1
def test_move_works_on_self_sent(acfactory):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
ac1.set_config("bcc_self", "1")
chat = acfactory.get_accepted_chat(ac1, ac2)
chat.send_text("message1")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
chat.send_text("message2")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
chat.send_text("message3")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
def test_move_sync_msgs(acfactory):
ac1 = acfactory.new_online_configuring_account(bcc_self=True, sync_msgs=True, fix_is_chatmail=True)
acfactory.bring_accounts_online()
@@ -607,39 +501,6 @@ def test_send_and_receive_message_markseen(acfactory, lp):
pass # mark_seen_messages() has generated events before it returns
def test_moved_markseen(acfactory):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
ac2.stop_io()
with ac2.direct_imap.idle() as idle2:
ac1.create_chat(ac2).send_text("Hello!")
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
ac2.direct_imap.conn.move(["*"], "DeltaChat")
ac2.direct_imap.select_folder("DeltaChat")
with ac2.direct_imap.idle() as idle2:
ac2.start_io()
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
assert msg.text == "Messages are end-to-end encrypted."
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
# Accept the contact request.
msg.chat.accept()
ac2.mark_seen_messages([msg])
uid = idle2.wait_for_seen()
assert len(list(ac2.direct_imap.conn.fetch(AND(seen=True, uid=U(uid, "*"))))) == 1
def test_message_override_sender_name(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("displayname", "ac1-default-displayname")
@@ -674,36 +535,6 @@ def test_message_override_sender_name(acfactory, lp):
assert not msg2.override_sender_name
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, mvbox_move):
# Please only change this test if you are very sure that it will still catch the issues it catches now.
# We had so many problems with markseen, if in doubt, rather create another test, it can't harm.
ac1 = acfactory.new_online_configuring_account(mvbox_move=mvbox_move)
ac2 = acfactory.new_online_configuring_account(mvbox_move=mvbox_move)
acfactory.bring_accounts_online()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2._evtracker.wait_next_incoming_message()
ac2.mark_seen_messages([msg])
folder = "mvbox" if mvbox_move else "inbox"
for ac in [ac1, ac2]:
if mvbox_move:
ac._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
ac._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
ac1.direct_imap.select_config_folder(folder)
ac2.direct_imap.select_config_folder(folder)
# Check that the mdn is marked as seen
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
# Check original message is marked as seen
assert len(list(ac2.direct_imap.conn.fetch(AND(seen=True)))) == 1
def test_reply_privately(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -853,140 +684,6 @@ def test_no_draft_if_cant_send(acfactory):
assert device_chat.get_draft() is None
def test_dont_show_emails(acfactory, lp):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
Also, test that unknown emails in the Spam folder are not shown."""
ac1 = acfactory.new_online_configuring_account()
ac1.set_config("show_emails", "2")
ac1.create_contact("alice@example.org").create_chat()
acfactory.wait_configured(ac1)
ac1.direct_imap.create_folder("Drafts")
ac1.direct_imap.create_folder("Spam")
ac1.direct_imap.create_folder("Junk")
acfactory.bring_accounts_online()
ac1.stop_io()
ac1.direct_imap.append(
"Drafts",
"""
From: ac1 <{}>
Subject: subj
To: alice@example.org
Message-ID: <aepiors@example.org>
Content-Type: text/plain; charset=utf-8
message in Drafts received later
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org, unkwnown.add@junk.org
Subject: subj
To: {}
Message-ID: <spam.message2@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: delta<address: inbox@nhroy.com>
Subject: subj
To: {}
Message-ID: <spam.message99@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: alice@example.org
Subject: subj
To: {}
Message-ID: <spam.message3@junk.org>
Content-Type: text/plain; charset=utf-8
Actually interesting message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Junk",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Junk
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.set_config("scan_all_folders_debounce_secs", "0")
lp.sec("All prepared, now let DC find the message")
ac1.start_io()
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
ac1._evtracker.wait_idle_inbox_ready()
fresh_msgs = list(ac1.get_fresh_messages())
msg = fresh_msgs[0]
chat_msgs = msg.chat.get_messages()
assert len(chat_msgs) == 1
assert any(msg.text == "subj Actually interesting message in Spam" for msg in chat_msgs)
assert not any("unknown.address" in c.get_name() for c in ac1.get_chats())
ac1.direct_imap.select_folder("Spam")
assert ac1.direct_imap.get_uid_by_message_id("spam.message@junk.org")
ac1.stop_io()
lp.sec("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
ac1.direct_imap.select_folder("Drafts")
uid = ac1.direct_imap.get_uid_by_message_id("aepiors@example.org")
ac1.direct_imap.conn.move(uid, "Inbox")
ac1.start_io()
msg2 = ac1._evtracker.wait_next_messages_changed()
assert msg2.text == "subj message in Drafts received later"
assert len(msg.chat.get_messages()) == 2
def test_bot(acfactory, lp):
"""Test that bot messages can be identified as such"""
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -1509,9 +1206,15 @@ def test_send_receive_locations(acfactory, lp):
assert locations[0].latitude == 2.0
assert locations[0].longitude == 3.0
assert locations[0].accuracy == 0.5
assert locations[0].timestamp > now
assert locations[0].marker is None
# Make sure the timestamp is not in the past.
# Note that location timestamp has only 1 second precision,
# while `now` has a fractional part, so we have to truncate it
# first, otherwise `now` may appear to be in the future
# even though it is the same second.
assert int(locations[0].timestamp.timestamp()) >= int(now.timestamp())
contact = ac2.create_contact(ac1)
locations2 = chat2.get_locations(contact=contact)
assert len(locations2) == 1
@@ -1522,38 +1225,6 @@ def test_send_receive_locations(acfactory, lp):
assert not locations3
def test_immediate_autodelete(acfactory, lp):
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
lp.sec("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)
ac2.create_chat(ac1)
lp.sec("ac1: send message to ac2")
sent_msg = chat1.send_text("hello")
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
lp.sec("ac2: wait for close/expunge on autodelete")
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
ac2._evtracker.get_info_contains("Close/expunge succeeded.")
lp.sec("ac2: check that message was autodeleted on server")
assert len(ac2.direct_imap.get_all_messages()) == 0
lp.sec("ac2: Mark deleted message as seen and check that read receipt arrives")
msg.mark_seen()
ev = ac1._evtracker.get_matching("DC_EVENT_MSG_READ")
assert ev.data1 == chat1.id
assert ev.data2 == sent_msg.id
def test_delete_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat12 = acfactory.get_accepted_chat(ac1, ac2)
@@ -1586,55 +1257,6 @@ def test_delete_multiple_messages(acfactory, lp):
break
def test_trash_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
lp.sec("Creating trash folder")
ac2.direct_imap.create_folder("Trash")
ac2.set_config("delete_to_trash", "1")
lp.sec("Check that Trash can be configured initially as well")
ac3 = acfactory.new_online_configuring_account(cloned_from=ac2)
acfactory.bring_accounts_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: sending 3 messages")
texts = ["first", "second", "third"]
for text in texts:
chat12.send_text(text)
lp.sec("ac2: waiting for all messages on the other side")
to_delete = []
for text in texts:
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
lp.sec("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1
ac2.delete_messages(to_delete)
lp.sec("ac2: test that only one message is left")
while 1:
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
ac2.direct_imap.select_config_folder("inbox")
nr_msgs = len(ac2.direct_imap.get_all_messages())
assert nr_msgs > 0
if nr_msgs == 1:
break
def test_configure_error_msgs_wrong_pw(acfactory):
(ac1,) = acfactory.get_online_accounts(1)
@@ -1673,16 +1295,17 @@ def test_configure_error_msgs_invalid_server(acfactory):
ev = ac2._evtracker.get_matching("DC_EVENT_CONFIGURE_PROGRESS")
if ev.data1 == 0:
break
err_lower = ev.data2.lower()
# Can't connect so it probably should say something about "internet"
# again, should not repeat itself
# If this fails then probably `e.msg.to_lowercase().contains("could not resolve")`
# in configure.rs returned false because the error message was changed
# (i.e. did not contain "could not resolve" anymore)
assert (ev.data2.count("internet") + ev.data2.count("network")) == 1
assert (err_lower.count("internet") + err_lower.count("network")) == 1
# Should mention that it can't connect:
assert ev.data2.count("connect") == 1
assert err_lower.count("connect") == 1
# The users do not know what "configuration" is
assert "configuration" not in ev.data2.lower()
assert "configuration" not in err_lower
def test_status(acfactory):
@@ -1758,71 +1381,6 @@ def test_group_quote(acfactory, lp):
assert received_reply.quote.id == out_msg.id
@pytest.mark.parametrize(
("folder", "move", "expected_destination"),
[
(
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
(
"xyz",
True,
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,
"INBOX",
), # ...emails are moved from the spam folder to the Inbox
],
)
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
def test_scan_folders(acfactory, lp, folder, move, expected_destination):
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
variant = folder + "-" + str(move) + "-" + expected_destination
lp.sec("Testing variant " + variant)
ac1 = acfactory.new_online_configuring_account(mvbox_move=move)
ac2 = acfactory.new_online_configuring_account()
acfactory.wait_configured(ac1)
ac1.direct_imap.create_folder(folder)
# Wait until each folder was selected once and we are IDLEing:
acfactory.bring_accounts_online()
ac1.stop_io()
assert folder in ac1.direct_imap.list_folders()
lp.sec("Send a message to from ac2 to ac1 and manually move it to `folder`")
ac1.direct_imap.select_config_folder("inbox")
with ac1.direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
idle1.wait_for_new_message()
ac1.direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
lp.sec("start_io() and see if DeltaChat finds the message (" + variant + ")")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
chat = ac1.create_chat(ac2)
n_msgs = 1 # "Messages are end-to-end encrypted."
if folder == "Spam":
msg = ac1._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
n_msgs += 1
else:
ac1._evtracker.wait_idle_inbox_ready()
assert len(chat.get_messages()) == n_msgs
# The message has reached its destination.
ac1.direct_imap.select_folder(expected_destination)
assert len(ac1.direct_imap.get_all_messages()) == 1
if folder != expected_destination:
ac1.direct_imap.select_folder(folder)
assert len(ac1.direct_imap.get_all_messages()) == 0
def test_archived_muted_chat(acfactory, lp):
"""If an archived and muted chat receives a new message, DC_EVENT_MSGS_CHANGED for
DC_CHAT_ID_ARCHIVED_LINK must be generated if the chat had only seen messages previously.

View File

@@ -35,7 +35,7 @@ class TestOfflineAccountBasic:
d = ac1.get_info()
assert d["arch"]
assert d["number_of_chats"] == "0"
assert d["bcc_self"] == "1"
assert d["bcc_self"] == "0"
def test_is_not_configured(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
@@ -69,7 +69,7 @@ class TestOfflineAccountBasic:
def test_has_bccself(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
assert "bcc_self" in ac1.get_config("sys.config_keys").split()
assert ac1.get_config("bcc_self") == "1"
assert ac1.get_config("bcc_self") == "0"
def test_selfcontact_if_unconfigured(self, acfactory):
ac1 = acfactory.get_unconfigured_account()

View File

@@ -46,7 +46,7 @@ deps =
commands =
ruff format --diff setup.py src/deltachat examples/ tests/
ruff check src/deltachat tests/ examples/
rst-lint --encoding 'utf-8' README.rst
rst-lint README.rst
[testenv:mypy]
deps =

View File

@@ -1 +1 @@
2025-11-05
2025-12-16

View File

@@ -26,10 +26,10 @@ and an own build machine.
i.e. `deltachat-rpc-client` and `deltachat-rpc-server`.
- `remote_tests_python.sh` rsyncs to a build machine and runs
`run-python-test.sh` remotely on the build machine.
JSON-RPC Python tests remotely on the build machine.
- `remote_tests_rust.sh` rsyncs to the build machine and runs
`run-rust-test.sh` remotely on the build machine.
Rust tests remotely on the build machine.
- `run-doxygen.sh` generates C-docs which are then uploaded to https://c.delta.chat/

View File

@@ -7,7 +7,7 @@ set -euo pipefail
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.91.0
RUST_VERSION=1.92.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

@@ -1,45 +1,32 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
BUILD_ID=${1:?specify build ID}
SSHTARGET=${SSHTARGET-ci@b1.delta.chat}
BUILDDIR=ci_builds/$BUILD_ID
set -x
if ! test -v SSHTARGET; then
echo >&2 SSHTARGET is not set
exit 1
fi
BUILDDIR=ci_builds/chatmailcore
echo "--- Copying files to $SSHTARGET:$BUILDDIR"
set -xe
ssh -oBatchMode=yes -oStrictHostKeyChecking=no $SSHTARGET mkdir -p "$BUILDDIR"
git ls-files >.rsynclist
# we seem to need .git for setuptools_scm versioning
find .git >>.rsynclist
rsync --delete --files-from=.rsynclist -az ./ "$SSHTARGET:$BUILDDIR"
set +x
rsync -az --delete --mkpath --files-from=<(git ls-files) ./ "$SSHTARGET:$BUILDDIR"
echo "--- Running Python tests remotely"
ssh $SSHTARGET <<_HERE
ssh -oBatchMode=yes -- "$SSHTARGET" <<_HERE
set +x -e
# make sure all processes exit when ssh dies
shopt -s huponexit
export RUSTC_WRAPPER=\`which sccache\`
export RUSTC_WRAPPER=\`command -v sccache\`
cd $BUILDDIR
export TARGET=release
export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN
#we rely on tox/virtualenv being available in the host
#rm -rf virtualenv venv
#virtualenv -q -p python3.7 venv
#source venv/bin/activate
#pip install -q tox virtualenv
scripts/make-rpc-testenv.sh
. venv/bin/activate
set -x
which python
source \$HOME/venv/bin/activate
which python
bash scripts/run-python-test.sh
cd deltachat-rpc-client
pytest -n6 $@
_HERE

View File

@@ -1,29 +1,25 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
BUILD_ID=${1:?specify build ID}
SSHTARGET=${SSHTARGET-ci@b1.delta.chat}
BUILDDIR=ci_builds/$BUILD_ID
set -e
if ! test -v SSHTARGET; then
echo >&2 SSHTARGET is not set
exit 1
fi
BUILDDIR=ci_builds/chatmailcore
echo "--- Copying files to $SSHTARGET:$BUILDDIR"
ssh -oBatchMode=yes -oStrictHostKeyChecking=no $SSHTARGET mkdir -p "$BUILDDIR"
git ls-files >.rsynclist
rsync --delete --files-from=.rsynclist -az ./ "$SSHTARGET:$BUILDDIR"
rsync -az --delete --mkpath --files-from=<(git ls-files) ./ "$SSHTARGET:$BUILDDIR"
echo "--- Running Rust tests remotely"
ssh $SSHTARGET <<_HERE
ssh -oBatchMode=yes -- "$SSHTARGET" <<_HERE
set +x -e
# make sure all processes exit when ssh dies
shopt -s huponexit
export RUSTC_WRAPPER=\`which sccache\`
export RUSTC_WRAPPER=\`command -v sccache\`
cd $BUILDDIR
export TARGET=x86_64-unknown-linux-gnu
export RUSTC_WRAPPER=sccache
bash scripts/run-rust-test.sh
cargo nextest run
_HERE

View File

@@ -31,6 +31,6 @@ unset CHATMAIL_DOMAIN
# Try to build wheels for a range of interpreters, but don't fail if they are not available.
# E.g. musllinux_1_1 does not have PyPy interpreters as of 2022-07-10
tox --workdir "$TOXWORKDIR" -e py38,py39,py310,py311,py312,py313,pypy38,pypy39,pypy310 --skip-missing-interpreters true
tox --workdir "$TOXWORKDIR" -e py310,py311,py312,py313,pypy310 --skip-missing-interpreters true
auditwheel repair "$TOXWORKDIR"/wheelhouse/deltachat* -w "$TOXWORKDIR/wheelhouse"

View File

@@ -3,8 +3,12 @@
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self, Receiver, Sender};
use futures::FutureExt as _;
use futures_lite::FutureExt as _;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -41,6 +45,13 @@ pub struct Accounts {
/// Push notification subscriber shared between accounts.
push_subscriber: PushSubscriber,
/// Channel sender to cancel ongoing background_fetch().
///
/// If background_fetch() is not running, this is `None`.
/// New background_fetch() should not be started if this
/// contains `Some`.
background_fetch_interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
}
impl Accounts {
@@ -96,6 +107,7 @@ impl Accounts {
events,
stockstrings,
push_subscriber,
background_fetch_interrupt_sender: Default::default(),
})
}
@@ -352,6 +364,11 @@ impl Accounts {
///
/// This is an auxiliary function and not part of public API.
/// Use [Accounts::background_fetch] instead.
///
/// This function is cancellation-safe.
/// It is intended to be cancellable,
/// either because of the timeout or because background
/// fetch was explicitly cancelled.
async fn background_fetch_no_timeout(accounts: Vec<Context>, events: Events) {
let n_accounts = accounts.len();
events.emit(Event {
@@ -360,6 +377,11 @@ impl Accounts {
"Starting background fetch for {n_accounts} accounts."
)),
});
::tracing::event!(
::tracing::Level::INFO,
account_id = 0,
"Starting background fetch for {n_accounts} accounts."
);
let mut set = JoinSet::new();
for account in accounts {
set.spawn(async move {
@@ -375,17 +397,41 @@ impl Accounts {
"Finished background fetch for {n_accounts} accounts."
)),
});
::tracing::event!(
::tracing::Level::INFO,
account_id = 0,
"Finished background fetch for {n_accounts} accounts."
);
}
/// Auxiliary function for [Accounts::background_fetch].
///
/// Runs `background_fetch` until it finishes
/// or until the timeout.
///
/// Produces `AccountsBackgroundFetchDone` event in every case
/// and clears [`Self::background_fetch_interrupt_sender`]
/// so a new background fetch can be started.
///
/// This function is not cancellation-safe.
/// Cancelling it before it returns may result
/// in not being able to run any new background fetch
/// if interrupt sender was not cleared.
async fn background_fetch_with_timeout(
accounts: Vec<Context>,
events: Events,
timeout: std::time::Duration,
interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
interrupt_receiver: Option<Receiver<()>>,
) {
let Some(interrupt_receiver) = interrupt_receiver else {
// Nothing to do if we got no interrupt receiver.
return;
};
if let Err(_err) = tokio::time::timeout(
timeout,
Self::background_fetch_no_timeout(accounts, events.clone()),
Self::background_fetch_no_timeout(accounts, events.clone())
.race(interrupt_receiver.recv().map(|_| ())),
)
.await
{
@@ -393,15 +439,26 @@ impl Accounts {
id: 0,
typ: EventType::Warning("Background fetch timed out.".to_string()),
});
::tracing::event!(
::tracing::Level::WARN,
account_id = 0,
"Background fetch timed out."
);
}
events.emit(Event {
id: 0,
typ: EventType::AccountsBackgroundFetchDone,
});
(*interrupt_sender.lock()) = None;
}
/// Performs a background fetch for all accounts in parallel with a timeout.
///
/// Ongoing background fetch can also be cancelled manually
/// by calling `stop_background_fetch()`, in which case it will
/// return immediately even before the timeout expiration
/// or finishing fetching.
///
/// 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.
@@ -414,7 +471,39 @@ impl Accounts {
) -> impl Future<Output = ()> + use<> {
let accounts: Vec<Context> = self.accounts.values().cloned().collect();
let events = self.events.clone();
Self::background_fetch_with_timeout(accounts, events, timeout)
let (sender, receiver) = async_channel::bounded(1);
let receiver = {
let mut lock = self.background_fetch_interrupt_sender.lock();
if (*lock).is_some() {
// Another background_fetch() is already running,
// return immeidately.
None
} else {
*lock = Some(sender);
Some(receiver)
}
};
Self::background_fetch_with_timeout(
accounts,
events,
timeout,
self.background_fetch_interrupt_sender.clone(),
receiver,
)
}
/// Interrupts ongoing background_fetch() call,
/// making it return early.
///
/// This method allows to cancel background_fetch() early,
/// e.g. on Android, when `Service.onTimeout` is called.
///
/// If there is no ongoing background_fetch(), does nothing.
pub fn stop_background_fetch(&self) {
let mut lock = self.background_fetch_interrupt_sender.lock();
if let Some(sender) = lock.take() {
sender.try_send(()).ok();
}
}
/// Emits a single event.
@@ -604,13 +693,12 @@ impl Config {
// Convert them to relative paths.
let mut modified = false;
for account in &mut config.inner.accounts {
if account.dir.is_absolute() {
if let Some(old_path_parent) = account.dir.parent() {
if let Ok(new_path) = account.dir.strip_prefix(old_path_parent) {
account.dir = new_path.to_path_buf();
modified = true;
}
}
if account.dir.is_absolute()
&& let Some(old_path_parent) = account.dir.parent()
&& let Ok(new_path) = account.dir.strip_prefix(old_path_parent)
{
account.dir = new_path.to_path_buf();
modified = true;
}
}
if modified && writable {

View File

@@ -234,8 +234,13 @@ impl<'a> BlobObject<'a> {
/// If `data` represents an image of known format, this adds the corresponding extension.
///
/// Even though this function is not async, it's OK to call it from an async context.
pub(crate) fn store_from_base64(context: &Context, data: &str) -> Result<String> {
let buf = base64::engine::general_purpose::STANDARD.decode(data)?;
///
/// Returns an error if there is an I/O problem,
/// but in case of a failure to decode base64 returns `Ok(None)`.
pub(crate) fn store_from_base64(context: &Context, data: &str) -> Result<Option<String>> {
let Ok(buf) = base64::engine::general_purpose::STANDARD.decode(data) else {
return Ok(None);
};
let name = if let Ok(format) = image::guess_format(&buf) {
if let Some(ext) = format.extensions_str().first() {
format!("file.{ext}")
@@ -246,7 +251,7 @@ impl<'a> BlobObject<'a> {
String::new()
};
let blob = BlobObject::create_and_deduplicate_from_bytes(context, &buf, &name)?;
Ok(blob.as_name().to_string())
Ok(Some(blob.as_name().to_string()))
}
/// Recode image to avatar size.

View File

@@ -173,11 +173,8 @@ async fn test_selfavatar_outside_blobdir() {
.unwrap();
let avatar_blob = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
let avatar_path = Path::new(&avatar_blob);
assert!(
avatar_blob.ends_with("7dde69e06b5ae6c27520a436bbfd65b.jpg"),
"The avatar filename should be its hash, put instead it's {avatar_blob}"
);
let scaled_avatar_size = file_size(avatar_path).await;
info!(&t, "Scaled avatar size: {scaled_avatar_size}.");
assert!(scaled_avatar_size < avatar_bytes.len() as u64);
check_image_size(avatar_src, 1000, 1000);
@@ -187,6 +184,11 @@ async fn test_selfavatar_outside_blobdir() {
constants::BALANCED_AVATAR_SIZE,
);
assert!(
avatar_blob.ends_with("2a048b6fcd86448032b854ea1ad7608.jpg"),
"The avatar filename should be its hash, but instead it's {avatar_blob}"
);
let mut blob = BlobObject::create_and_deduplicate(&t, avatar_path, avatar_path).unwrap();
let viewtype = &mut Viewtype::Image;
let strict_limits = true;

View File

@@ -6,15 +6,15 @@ use crate::chat::ChatIdBlocked;
use crate::chat::{Chat, ChatId, send_msg};
use crate::constants::{Blocked, Chattype};
use crate::contact::ContactId;
use crate::context::Context;
use crate::context::{Context, WeakContext};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::warn;
use crate::message::{self, Message, MsgId, Viewtype};
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
use crate::tools::time;
use crate::tools::{normalize_text, time};
use anyhow::{Context as _, Result, ensure};
use sdp::SessionDescription;
use serde::Serialize;
@@ -33,7 +33,7 @@ use tokio::time::sleep;
///
/// For the caller, this means they should also not wait longer,
/// as the callee won't start the call afterwards.
const RINGING_SECONDS: i64 = 60;
const RINGING_SECONDS: i64 = 120;
// For persisting parameters in the call, we use Param::Arg*
@@ -86,7 +86,7 @@ impl CallInfo {
.sql
.execute(
"UPDATE msgs SET txt=?, txt_normalized=? WHERE id=?",
(text, message::normalize_text(text), self.msg.id),
(text, normalize_text(text), self.msg.id),
)
.await?;
Ok(())
@@ -199,8 +199,9 @@ impl Context {
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
let context = self.get_weak_context();
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
context,
wait.try_into()?,
call.id,
));
@@ -291,11 +292,12 @@ impl Context {
}
async fn emit_end_call_if_unaccepted(
context: Context,
context: WeakContext,
wait: u64,
call_id: MsgId,
) -> Result<()> {
sleep(Duration::from_secs(wait)).await;
let context = context.upgrade()?;
let Some(mut call) = context.load_call_by_id(call_id).await? else {
warn!(
context,
@@ -368,8 +370,9 @@ impl Context {
}
}
let wait = call.remaining_ring_seconds();
let context = self.get_weak_context();
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
context,
wait.try_into()?,
call.msg.id,
));
@@ -660,9 +663,7 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
// because of bandwidth costs:
// <https://github.com/jselbie/stunserver/issues/50>
// We use nine.testrun.org for a default STUN server.
let hostname = "nine.testrun.org";
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
@@ -670,14 +671,27 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let ice_server = IceServer {
let stun_server = IceServer {
urls,
username: None,
credential: None,
};
let json = serde_json::to_string(&[ice_server])?;
let hostname = "turn.delta.chat";
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let turn_server = IceServer {
urls,
username: Some("public".to_string()),
credential: Some("o4tR7yG4rG2slhXqRUf9zgmHz".to_string()),
};
let json = serde_json::to_string(&[stun_server, turn_server])?;
Ok(json)
}

View File

@@ -45,7 +45,7 @@ use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_secret, create_id,
create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
gm2local_offset, smeared_time, time, truncate_msg_text,
gm2local_offset, normalize_text, smeared_time, time, truncate_msg_text,
};
use crate::webxdc::StatusUpdateSerial;
use crate::{chatlist_events, imap};
@@ -286,10 +286,11 @@ impl ChatId {
let timestamp = cmp::min(timestamp, smeared_time(context));
let row_id =
context.sql.insert(
"INSERT INTO chats (type, name, grpid, blocked, created_timestamp, protected, param) VALUES(?, ?, ?, ?, ?, 0, ?);",
"INSERT INTO chats (type, name, name_normalized, grpid, blocked, created_timestamp, protected, param) VALUES(?, ?, ?, ?, ?, ?, 0, ?)",
(
chattype,
&grpname,
normalize_text(&grpname),
grpid,
create_blocked,
timestamp,
@@ -301,7 +302,7 @@ impl ChatId {
let chat = Chat::load_from_db(context, chat_id).await?;
if chat.is_encrypted(context).await? {
chat_id.add_encrypted_msg(context, timestamp).await?;
chat_id.add_e2ee_notice(context, timestamp).await?;
}
info!(
@@ -431,14 +432,18 @@ impl ChatId {
match chat.typ {
Chattype::Single | Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast => {
// User has "created a chat" with all these contacts.
//
// Previously accepting a chat literally created a chat because unaccepted chats
// went to "contact requests" list rather than normal chatlist.
// But for groups we use lower origin because users don't always check all members
// before accepting a chat and may not want to have the group members mixed with
// existing contacts. `IncomingTo` fits here by its definition.
let origin = match chat.typ {
Chattype::Group => Origin::IncomingTo,
_ => Origin::CreateChat,
};
for contact_id in get_chat_contacts(context, self).await? {
if contact_id != ContactId::SELF {
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat)
.await?;
ContactId::scaleup_origin(context, &[contact_id], origin).await?;
}
}
}
@@ -462,19 +467,15 @@ impl ChatId {
}
/// Adds message "Messages are end-to-end encrypted".
pub(crate) async fn add_encrypted_msg(
self,
context: &Context,
timestamp_sort: i64,
) -> Result<()> {
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
let text = stock_str::messages_e2e_encrypted(context).await;
add_info_msg_with_cmd(
context,
self,
&text,
SystemMessage::ChatE2ee,
timestamp_sort,
None,
Some(timestamp),
timestamp,
None,
None,
None,
@@ -740,16 +741,15 @@ impl ChatId {
}
}
_ => {
if msg.viewtype == Viewtype::File {
if let Some((better_type, _)) = message::guess_msgtype_from_suffix(msg)
if msg.viewtype == Viewtype::File
&& let Some((better_type, _)) = message::guess_msgtype_from_suffix(msg)
// We do not do an automatic conversion to other viewtypes here so that
// users can send images as "files" to preserve the original quality
// (usually we compress images). The remaining conversions are done by
// `prepare_msg_blob()` later.
.filter(|&(vt, _)| vt == Viewtype::Webxdc || vt == Viewtype::Vcard)
{
msg.viewtype = better_type;
}
{
msg.viewtype = better_type;
}
if msg.viewtype == Viewtype::Vcard {
let blob = msg
@@ -767,13 +767,13 @@ impl ChatId {
msg.chat_id = self;
// if possible, replace existing draft and keep id
if !msg.id.is_special() {
if let Some(old_draft) = self.get_draft(context).await? {
if old_draft.id == msg.id
&& old_draft.chat_id == self
&& old_draft.state == MessageState::OutDraft
{
let affected_rows = context
if !msg.id.is_special()
&& let Some(old_draft) = self.get_draft(context).await?
&& old_draft.id == msg.id
&& old_draft.chat_id == self
&& old_draft.state == MessageState::OutDraft
{
let affected_rows = context
.sql.execute(
"UPDATE msgs
SET timestamp=?1,type=?2,txt=?3,txt_normalized=?4,param=?5,mime_in_reply_to=?6
@@ -787,15 +787,13 @@ impl ChatId {
time(),
msg.viewtype,
&msg.text,
message::normalize_text(&msg.text),
normalize_text(&msg.text),
msg.param.to_string(),
msg.in_reply_to.as_deref().unwrap_or_default(),
msg.id,
),
).await?;
return Ok(affected_rows > 0);
}
}
return Ok(affected_rows > 0);
}
let row_id = context
@@ -830,7 +828,7 @@ impl ChatId {
msg.viewtype,
MessageState::OutDraft,
&msg.text,
message::normalize_text(&msg.text),
normalize_text(&msg.text),
msg.param.to_string(),
1,
msg.in_reply_to.as_deref().unwrap_or_default(),
@@ -993,11 +991,11 @@ impl ChatId {
let mut res = Vec::new();
let now = time();
for (chat_id, metric) in chats_with_metrics {
if let Some(chat_timestamp) = chat_id.get_timestamp(context).await? {
if now > chat_timestamp + 42 * 24 * 3600 {
// Chat was inactive for 42 days, skip.
continue;
}
if let Some(chat_timestamp) = chat_id.get_timestamp(context).await?
&& now > chat_timestamp + 42 * 24 * 3600
{
// Chat was inactive for 42 days, skip.
continue;
}
if metric < 0.1 {
@@ -1252,10 +1250,10 @@ impl ChatId {
None
};
if let Some(last_msg_time) = last_msg_time {
if last_msg_time > sort_timestamp {
sort_timestamp = last_msg_time;
}
if let Some(last_msg_time) = last_msg_time
&& last_msg_time > sort_timestamp
{
sort_timestamp = last_msg_time;
}
Ok(sort_timestamp)
@@ -1376,10 +1374,10 @@ impl Chat {
let mut chat_name = "Err [Name not found]".to_owned();
match get_chat_contacts(context, chat.id).await {
Ok(contacts) => {
if let Some(contact_id) = contacts.first() {
if let Ok(contact) = Contact::get_by_id(context, *contact_id).await {
contact.get_display_name().clone_into(&mut chat_name);
}
if let Some(contact_id) = contacts.first()
&& let Ok(contact) = Contact::get_by_id(context, *contact_id).await
{
contact.get_display_name().clone_into(&mut chat_name);
}
}
Err(err) => {
@@ -1576,10 +1574,10 @@ impl Chat {
if self.typ == Chattype::Single {
let contacts = get_chat_contacts(context, self.id).await?;
if let Some(contact_id) = contacts.first() {
if let Ok(contact) = Contact::get_by_id(context, *contact_id).await {
color = contact.get_color();
}
if let Some(contact_id) = contacts.first()
&& let Ok(contact) = Contact::get_by_id(context, *contact_id).await
{
color = contact.get_color();
}
} else if !self.grpid.is_empty() {
color = str_to_color(&self.grpid);
@@ -1841,8 +1839,8 @@ impl Chat {
}
// add independent location to database
if msg.param.exists(Param::SetLatitude) {
if let Ok(row_id) = context
if msg.param.exists(Param::SetLatitude)
&& let Ok(row_id) = context
.sql
.insert(
"INSERT INTO locations \
@@ -1857,9 +1855,8 @@ impl Chat {
),
)
.await
{
location_id = row_id;
}
{
location_id = row_id;
}
let ephemeral_timer = if msg.param.get_cmd() == SystemMessage::EphemeralTimerChanged {
@@ -1927,7 +1924,7 @@ impl Chat {
msg.viewtype,
msg.state,
msg_text,
message::normalize_text(&msg_text),
normalize_text(&msg_text),
&msg.subject,
msg.param.to_string(),
msg.hidden,
@@ -1978,7 +1975,7 @@ impl Chat {
msg.viewtype,
msg.state,
msg_text,
message::normalize_text(&msg_text),
normalize_text(&msg_text),
&msg.subject,
msg.param.to_string(),
msg.hidden,
@@ -2282,8 +2279,8 @@ async fn update_special_chat_name(
context
.sql
.execute(
"UPDATE chats SET name=? WHERE id=? AND name!=?",
(&name, chat_id, &name),
"UPDATE chats SET name=?, name_normalized=? WHERE id=? AND name!=?",
(&name, normalize_text(&name), chat_id, &name),
)
.await?;
}
@@ -2396,11 +2393,12 @@ impl ChatIdBlocked {
.transaction(move |transaction| {
transaction.execute(
"INSERT INTO chats
(type, name, param, blocked, created_timestamp)
VALUES(?, ?, ?, ?, ?)",
(type, name, name_normalized, param, blocked, created_timestamp)
VALUES(?, ?, ?, ?, ?, ?)",
(
Chattype::Single,
chat_name,
&chat_name,
normalize_text(&chat_name),
params.to_string(),
create_blocked as u8,
smeared_time,
@@ -2429,7 +2427,7 @@ impl ChatIdBlocked {
&& !chat.param.exists(Param::Devicetalk)
&& !chat.param.exists(Param::Selftalk)
{
chat_id.add_encrypted_msg(context, smeared_time).await?;
chat_id.add_e2ee_notice(context, smeared_time).await?;
}
Ok(Self {
@@ -2497,18 +2495,18 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
msg.param.set(Param::File, blob.as_name());
}
if !msg.param.exists(Param::MimeType) {
if let Some((viewtype, mime)) = message::guess_msgtype_from_suffix(msg) {
// If we unexpectedly didn't recognize the file as image, don't send it as such,
// either the format is unsupported or the image is corrupted.
let mime = match viewtype != Viewtype::Image
|| matches!(msg.viewtype, Viewtype::Image | Viewtype::Sticker)
{
true => mime,
false => "application/octet-stream",
};
msg.param.set(Param::MimeType, mime);
}
if !msg.param.exists(Param::MimeType)
&& let Some((viewtype, mime)) = message::guess_msgtype_from_suffix(msg)
{
// If we unexpectedly didn't recognize the file as image, don't send it as such,
// either the format is unsupported or the image is corrupted.
let mime = match viewtype != Viewtype::Image
|| matches!(msg.viewtype, Viewtype::Image | Viewtype::Sticker)
{
true => mime,
false => "application/octet-stream",
};
msg.param.set(Param::MimeType, mime);
}
msg.try_calc_and_set_dimensions(context).await?;
@@ -2692,15 +2690,15 @@ async fn prepare_send_msg(
// This is meant as a last line of defence, the UI should check that before as well.
// (We allow Chattype::Single in general for "Reply Privately";
// checking for exact contact_id will produce false positives when ppl just left the group)
if chat.typ != Chattype::Single && !context.get_config_bool(Config::Bot).await? {
if let Some(quoted_message) = msg.quoted_message(context).await? {
if quoted_message.chat_id != chat_id {
bail!(
"Quote of message from {} cannot be sent to {chat_id}",
quoted_message.chat_id
);
}
}
if chat.typ != Chattype::Single
&& !context.get_config_bool(Config::Bot).await?
&& let Some(quoted_message) = msg.quoted_message(context).await?
&& quoted_message.chat_id != chat_id
{
bail!(
"Quote of message from {} cannot be sent to {chat_id}",
quoted_message.chat_id
);
}
// check current MessageState for drafts (to keep msg_id) ...
@@ -2830,16 +2828,15 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let now = smeared_time(context);
if rendered_msg.last_added_location_id.is_some() {
if let Err(err) = location::set_kml_sent_timestamp(context, msg.chat_id, now).await {
error!(context, "Failed to set kml sent_timestamp: {err:#}.");
}
if rendered_msg.last_added_location_id.is_some()
&& let Err(err) = location::set_kml_sent_timestamp(context, msg.chat_id, now).await
{
error!(context, "Failed to set kml sent_timestamp: {err:#}.");
}
if attach_selfavatar {
if let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, now).await {
error!(context, "Failed to set selfavatar timestamp: {err:#}.");
}
if attach_selfavatar && let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, now).await
{
error!(context, "Failed to set selfavatar timestamp: {err:#}.");
}
if rendered_msg.is_encrypted {
@@ -2953,7 +2950,7 @@ pub(crate) async fn save_text_edit_to_db(
"UPDATE msgs SET txt=?, txt_normalized=?, param=? WHERE id=?",
(
new_text,
message::normalize_text(new_text),
normalize_text(new_text),
original_msg.param.to_string(),
original_msg.id,
),
@@ -3097,7 +3094,7 @@ pub async fn get_chat_msgs_ex(
WHERE m.chat_id=?
AND m.hidden=0
AND (
m.param GLOB \"*S=*\"
m.param GLOB '*\nS=*' OR param GLOB 'S=*'
OR m.from_id == ?
OR m.to_id == ?
);",
@@ -3442,9 +3439,15 @@ pub(crate) async fn create_group_ex(
.sql
.insert(
"INSERT INTO chats
(type, name, grpid, param, created_timestamp)
VALUES(?, ?, ?, \'U=1\', ?);",
(Chattype::Group, &chat_name, &grpid, timestamp),
(type, name, name_normalized, grpid, param, created_timestamp)
VALUES(?, ?, ?, ?, \'U=1\', ?)",
(
Chattype::Group,
&chat_name,
normalize_text(&chat_name),
&grpid,
timestamp,
),
)
.await?;
@@ -3457,14 +3460,20 @@ pub(crate) async fn create_group_ex(
if !grpid.is_empty() {
// Add "Messages are end-to-end encrypted." message.
chat_id.add_encrypted_msg(context, timestamp).await?;
chat_id.add_e2ee_notice(context, timestamp).await?;
}
if !context.get_config_bool(Config::Bot).await?
&& !context.get_config_bool(Config::SkipStartMessages).await?
{
let text = stock_str::new_group_send_first_message(context).await;
add_info_msg(context, chat_id, &text, create_smeared_timestamp(context)).await?;
let text = if !grpid.is_empty() {
// Add "Others will only see this group after you sent a first message." message.
stock_str::new_group_send_first_message(context).await
} else {
// Add "Messages in this chat use classic email and are not encrypted." message.
stock_str::chat_unencrypted_explanation(context).await
};
add_info_msg(context, chat_id, &text).await?;
}
if let (true, true) = (sync.into(), !grpid.is_empty()) {
let id = SyncId::Grpid(grpid);
@@ -3522,9 +3531,15 @@ pub(crate) async fn create_out_broadcast_ex(
t.execute(
"INSERT INTO chats
(type, name, grpid, created_timestamp)
VALUES(?, ?, ?, ?);",
(Chattype::OutBroadcast, &chat_name, &grpid, timestamp),
(type, name, name_normalized, grpid, created_timestamp)
VALUES(?, ?, ?, ?, ?)",
(
Chattype::OutBroadcast,
&chat_name,
normalize_text(&chat_name),
&grpid,
timestamp,
),
)?;
let chat_id = ChatId::new(t.last_insert_rowid().try_into()?);
@@ -3532,7 +3547,7 @@ pub(crate) async fn create_out_broadcast_ex(
Ok(chat_id)
};
let chat_id = context.sql.transaction(trans_fn).await?;
chat_id.add_encrypted_msg(context, timestamp).await?;
chat_id.add_e2ee_notice(context, timestamp).await?;
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
@@ -3733,10 +3748,16 @@ pub(crate) async fn add_contact_to_chat_ex(
chat.typ != Chattype::OutBroadcast || contact_id != ContactId::SELF,
"Cannot add SELF to broadcast channel."
);
ensure!(
chat.is_encrypted(context).await? == contact.is_key_contact(),
"Only key-contacts can be added to encrypted chats"
);
match chat.is_encrypted(context).await? {
true => ensure!(
contact.is_key_contact(),
"Only key-contacts can be added to encrypted chats"
),
false => ensure!(
!contact.is_key_contact(),
"Only address-contacts can be added to unencrypted chats"
),
}
if !chat.is_self_in_chat(context).await? {
context.emit_event(EventType::ErrorSelfNotInGroup(
@@ -4091,8 +4112,8 @@ async fn rename_ex(
context
.sql
.execute(
"UPDATE chats SET name=? WHERE id=?;",
(new_name.to_string(), chat_id),
"UPDATE chats SET name=?, name_normalized=? WHERE id=?",
(&new_name, normalize_text(&new_name), chat_id),
)
.await?;
if chat.is_promoted()
@@ -4186,6 +4207,16 @@ pub async fn set_chat_profile_image(
/// Forwards multiple messages to a chat.
pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId) -> Result<()> {
forward_msgs_2ctx(context, msg_ids, context, chat_id).await
}
/// Forwards multiple messages to a chat in another context.
pub async fn forward_msgs_2ctx(
ctx_src: &Context,
msg_ids: &[MsgId],
ctx_dst: &Context,
chat_id: ChatId,
) -> Result<()> {
ensure!(!msg_ids.is_empty(), "empty msgs_ids: nothing to forward");
ensure!(!chat_id.is_special(), "can not forward to special chat");
@@ -4193,16 +4224,16 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
let mut curr_timestamp: i64;
chat_id
.unarchive_if_not_muted(context, MessageState::Undefined)
.unarchive_if_not_muted(ctx_dst, MessageState::Undefined)
.await?;
let mut chat = Chat::load_from_db(context, chat_id).await?;
if let Some(reason) = chat.why_cant_send(context).await? {
let mut chat = Chat::load_from_db(ctx_dst, chat_id).await?;
if let Some(reason) = chat.why_cant_send(ctx_dst).await? {
bail!("cannot send to {chat_id}: {reason}");
}
curr_timestamp = create_smeared_timestamps(context, msg_ids.len());
curr_timestamp = create_smeared_timestamps(ctx_dst, msg_ids.len());
let mut msgs = Vec::with_capacity(msg_ids.len());
for id in msg_ids {
let ts: i64 = context
let ts: i64 = ctx_src
.sql
.query_get_value("SELECT timestamp FROM msgs WHERE id=?", (id,))
.await?
@@ -4212,11 +4243,14 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
msgs.sort_unstable();
for (_, id) in msgs {
let src_msg_id: MsgId = id;
let mut msg = Message::load_from_db(context, src_msg_id).await?;
let mut msg = Message::load_from_db(ctx_src, src_msg_id).await?;
if msg.state == MessageState::OutDraft {
bail!("cannot forward drafts.");
}
let mut param = msg.param;
msg.param = Params::new();
if msg.get_viewtype() != Viewtype::Sticker {
msg.param
.set_int(Param::Forwarded, src_msg_id.to_u32() as i32);
@@ -4226,17 +4260,16 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
msg.viewtype = Viewtype::Text;
}
msg.param.remove(Param::GuaranteeE2ee);
msg.param.remove(Param::ForcePlaintext);
msg.param.remove(Param::Cmd);
msg.param.remove(Param::OverrideSenderDisplayname);
msg.param.remove(Param::WebxdcDocument);
msg.param.remove(Param::WebxdcDocumentTimestamp);
msg.param.remove(Param::WebxdcSummary);
msg.param.remove(Param::WebxdcSummaryTimestamp);
msg.param.remove(Param::IsEdited);
msg.param.remove(Param::WebrtcRoom);
msg.param.remove(Param::WebrtcAccepted);
let param = &mut param;
msg.param.steal(param, Param::File);
msg.param.steal(param, Param::Filename);
msg.param.steal(param, Param::Width);
msg.param.steal(param, Param::Height);
msg.param.steal(param, Param::Duration);
msg.param.steal(param, Param::MimeType);
msg.param.steal(param, Param::ProtectQuote);
msg.param.steal(param, Param::Quote);
msg.param.steal(param, Param::Summary1);
msg.in_reply_to = None;
// do not leak data as group names; a default subject is generated by mimefactory
@@ -4245,16 +4278,16 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
msg.state = MessageState::OutPending;
msg.rfc724_mid = create_outgoing_rfc724_mid();
msg.timestamp_sort = curr_timestamp;
chat.prepare_msg_raw(context, &mut msg, None).await?;
chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?;
curr_timestamp += 1;
if !create_send_msg_jobs(context, &mut msg).await?.is_empty() {
context.scheduler.interrupt_smtp().await;
if !create_send_msg_jobs(ctx_dst, &mut msg).await?.is_empty() {
ctx_dst.scheduler.interrupt_smtp().await;
}
created_msgs.push(msg.id);
}
for msg_id in created_msgs {
context.emit_msgs_changed(chat_id, msg_id);
ctx_dst.emit_msgs_changed(chat_id, msg_id);
}
Ok(())
}
@@ -4477,11 +4510,11 @@ pub async fn add_device_msg_with_importance(
let mut chat_id = ChatId::new(0);
let mut msg_id = MsgId::new_unset();
if let Some(label) = label {
if was_device_msg_ever_added(context, label).await? {
info!(context, "Device-message {label} already added.");
return Ok(msg_id);
}
if let Some(label) = label
&& was_device_msg_ever_added(context, label).await?
{
info!(context, "Device-message {label} already added.");
return Ok(msg_id);
}
if let Some(msg) = msg {
@@ -4493,10 +4526,10 @@ pub async fn add_device_msg_with_importance(
// makes sure, the added message is the last one,
// even if the date is wrong (useful esp. when warning about bad dates)
msg.timestamp_sort = timestamp_sent;
if let Some(last_msg_time) = chat_id.get_timestamp(context).await? {
if msg.timestamp_sort <= last_msg_time {
msg.timestamp_sort = last_msg_time + 1;
}
if let Some(last_msg_time) = chat_id.get_timestamp(context).await?
&& msg.timestamp_sort <= last_msg_time
{
msg.timestamp_sort = last_msg_time + 1;
}
prepare_msg_blob(context, msg).await?;
let state = MessageState::InFresh;
@@ -4526,7 +4559,7 @@ pub async fn add_device_msg_with_importance(
msg.viewtype,
state,
&msg.text,
message::normalize_text(&msg.text),
normalize_text(&msg.text),
msg.param.to_string(),
rfc724_mid,
),
@@ -4615,9 +4648,11 @@ pub(crate) async fn add_info_msg_with_cmd(
chat_id: ChatId,
text: &str,
cmd: SystemMessage,
timestamp_sort: i64,
// Timestamp to show to the user (if this is None, `timestamp_sort` will be shown to the user)
timestamp_sent_rcvd: Option<i64>,
// Timestamp where in the chat the message will be sorted.
// If this is None, the message will be sorted to the bottom.
timestamp_sort: Option<i64>,
// Timestamp to show to the user
timestamp_sent_rcvd: i64,
parent: Option<&Message>,
from_id: Option<ContactId>,
added_removed_id: Option<ContactId>,
@@ -4633,6 +4668,22 @@ pub(crate) async fn add_info_msg_with_cmd(
param.set(Param::ContactAddedRemoved, contact_id.to_u32().to_string());
}
let timestamp_sort = if let Some(ts) = timestamp_sort {
ts
} else {
let sort_to_bottom = true;
let (received, incoming) = (false, false);
chat_id
.calc_sort_timestamp(
context,
smeared_time(context),
sort_to_bottom,
received,
incoming,
)
.await?
};
let row_id =
context.sql.insert(
"INSERT INTO msgs (chat_id,from_id,to_id,timestamp,timestamp_sent,timestamp_rcvd,type,state,txt,txt_normalized,rfc724_mid,ephemeral_timer,param,mime_in_reply_to)
@@ -4642,12 +4693,12 @@ pub(crate) async fn add_info_msg_with_cmd(
from_id.unwrap_or(ContactId::INFO),
ContactId::INFO,
timestamp_sort,
timestamp_sent_rcvd.unwrap_or(0),
timestamp_sent_rcvd.unwrap_or(0),
timestamp_sent_rcvd,
timestamp_sent_rcvd,
Viewtype::Text,
MessageState::InNoticed,
text,
message::normalize_text(text),
normalize_text(text),
rfc724_mid,
ephemeral_timer,
param.to_string(),
@@ -4663,19 +4714,14 @@ pub(crate) async fn add_info_msg_with_cmd(
}
/// Adds info message with a given text and `timestamp` to the chat.
pub(crate) async fn add_info_msg(
context: &Context,
chat_id: ChatId,
text: &str,
timestamp: i64,
) -> Result<MsgId> {
pub(crate) async fn add_info_msg(context: &Context, chat_id: ChatId, text: &str) -> Result<MsgId> {
add_info_msg_with_cmd(
context,
chat_id,
text,
SystemMessage::Unknown,
timestamp,
None,
time(),
None,
None,
None,
@@ -4694,7 +4740,7 @@ pub(crate) async fn update_msg_text_and_timestamp(
.sql
.execute(
"UPDATE msgs SET txt=?, txt_normalized=?, timestamp=? WHERE id=?;",
(text, message::normalize_text(text), timestamp, msg_id),
(text, normalize_text(text), timestamp, msg_id),
)
.await?;
context.emit_msgs_changed(chat_id, msg_id);

View File

@@ -815,15 +815,6 @@ async fn test_self_talk() -> Result<()> {
assert!(msg.get_showpadlock());
let sent_msg = t.pop_sent_msg().await;
let payload = sent_msg.payload();
// Make sure the `To` field contains the address and not
// "undisclosed recipients".
// Otherwise Delta Chat core <1.153.0 assigns the message
// to the trash chat.
assert_eq!(
payload.match_indices("To: <alice@example.org>\r\n").count(),
1
);
let t2 = TestContext::new_alice().await;
t2.recv_msg(&sent_msg).await;
@@ -1238,7 +1229,7 @@ async fn test_unarchive_if_muted() -> Result<()> {
chat_id.set_visibility(&t, ChatVisibility::Archived).await?;
set_muted(&t, chat_id, MuteDuration::Forever).await?;
send_text_msg(&t, chat_id, "out".to_string()).await?;
add_info_msg(&t, chat_id, "info", time()).await?;
add_info_msg(&t, chat_id, "info").await?;
assert_eq!(get_archived_cnt(&t).await?, 1);
// finally, unarchive on sending to not muted chat
@@ -1637,7 +1628,7 @@ async fn test_set_mute_duration() {
async fn test_add_info_msg() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group(&t, "foo").await?;
add_info_msg(&t, chat_id, "foo info", time()).await?;
add_info_msg(&t, chat_id, "foo info").await?;
let msg = t.get_last_msg_in(chat_id).await;
assert_eq!(msg.get_chat_id(), chat_id);
@@ -1659,8 +1650,8 @@ async fn test_add_info_msg_with_cmd() -> Result<()> {
chat_id,
"foo bar info",
SystemMessage::EphemeralTimerChanged,
time(),
None,
time(),
None,
None,
None,
@@ -1904,12 +1895,6 @@ async fn test_classic_email_chat() -> Result<()> {
let msgs = get_chat_msgs(&alice, chat_id).await?;
assert_eq!(msgs.len(), 1);
// Alice disables receiving classic emails.
alice
.set_config(Config::ShowEmails, Some("0"))
.await
.unwrap();
// Already received classic email should still be in the chat.
assert_eq!(chat_id.get_fresh_msg_cnt(&alice).await?, 1);
@@ -2632,12 +2617,6 @@ async fn test_can_send_group() -> Result<()> {
/// the recipients can't see the identity of their fellow recipients.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
fn contains(parsed: &MimeMessage, s: &str) -> bool {
assert_eq!(parsed.decrypting_failed, false);
let decoded_str = std::str::from_utf8(&parsed.decoded_data).unwrap();
decoded_str.contains(s)
}
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
@@ -2669,8 +2648,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
);
let parsed = charlie.parse_msg(&auth_required).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&auth_required).await;
assert!(parsed_by_bob.decrypting_failed);
@@ -2698,8 +2677,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
);
let parsed = charlie.parse_msg(&member_added).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_added).await;
assert!(parsed_by_bob.decrypting_failed);
@@ -2713,8 +2692,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
let hi_msg = alice.send_text(alice_broadcast_id, "hi").await;
let parsed = charlie.parse_msg(&hi_msg).await;
assert_eq!(parsed.header_exists(HeaderDef::AutocryptGossip), false);
assert_eq!(contains(&parsed, "charlie@example.net"), false);
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&hi_msg).await;
assert_eq!(parsed_by_bob.decrypting_failed, false);
@@ -2730,8 +2709,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
"charlie@example.net alice@example.org"
);
let parsed = charlie.parse_msg(&member_removed).await;
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_removed).await;
assert!(parsed_by_bob.decrypting_failed);
@@ -3327,10 +3306,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
let leave_msg = bob0.pop_sent_msg().await;
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes(), None).await?;
assert_eq!(
parsed.parts[0].msg,
stock_str::msg_group_left_remote(bob0).await
);
assert_eq!(parsed.parts[0].msg, "I left the group.");
let rcvd = bob1.recv_msg(&leave_msg).await;
@@ -4513,7 +4489,7 @@ async fn test_info_not_referenced() -> Result<()> {
let bob_received_message = tcm.send_recv_accept(alice, bob, "Hi!").await;
let bob_chat_id = bob_received_message.chat_id;
add_info_msg(bob, bob_chat_id, "Some info", create_smeared_timestamp(bob)).await?;
add_info_msg(bob, bob_chat_id, "Some info").await?;
// Bob sends a message.
// This message should reference Alice's "Hi!" message and not the info message.
@@ -5258,6 +5234,44 @@ async fn test_send_delete_request_no_encryption() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_msgs_2ctx() -> 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 alice_sent = alice.send_text(alice_chat.id, "hi").await;
let bob_alice_msg = bob.recv_msg(&alice_sent).await;
let bob_chat_id = bob_alice_msg.chat_id;
bob_chat_id.accept(bob).await?;
let bob_text = "Hi, did you know we're using the same device so i have access to your profile?";
let bob_sent = bob.send_text(bob_chat_id, bob_text).await;
alice.recv_msg(&bob_sent).await;
let alice_chat_len = alice_chat.id.get_msg_cnt(alice).await?;
forward_msgs_2ctx(
bob,
&[bob_alice_msg.id, bob_sent.sender_msg_id],
alice,
alice_chat.id,
)
.await?;
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, alice_chat_len + 2);
let msg = alice.get_last_msg().await;
assert!(msg.is_forwarded());
assert_eq!(msg.text, bob_text);
assert_eq!(msg.from_id, ContactId::SELF);
let sent = alice.pop_sent_msg().await;
let msg = bob.recv_msg(&sent).await;
assert!(msg.is_forwarded());
assert_eq!(msg.text, bob_text);
assert_eq!(msg.from_id, bob_alice_msg.from_id);
Ok(())
}
/// Tests that in multi-device setup
/// second device learns the key of a contact
/// via Autocrypt-Gossip in 1:1 chats.

View File

@@ -185,7 +185,7 @@ impl Chatlist {
warn!(context, "Cannot update special chat names: {err:#}.")
}
let str_like_cmd = format!("%{query}%");
let str_like_cmd = format!("%{}%", query.to_lowercase());
context
.sql
.query_map_vec(
@@ -201,7 +201,7 @@ impl Chatlist {
ORDER BY timestamp DESC, id DESC LIMIT 1)
WHERE c.id>9 AND c.id!=?2
AND c.blocked!=1
AND c.name LIKE ?3
AND IFNULL(c.name_normalized,c.name) LIKE ?3
AND (NOT ?4 OR EXISTS (SELECT 1 FROM msgs m WHERE m.chat_id = c.id AND m.state == ?5 AND hidden=0))
GROUP BY c.id
ORDER BY IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
@@ -472,7 +472,7 @@ mod tests {
use crate::chat::save_msgs;
use crate::chat::{
add_contact_to_chat, create_group, get_chat_contacts, remove_contact_from_chat,
send_text_msg,
send_text_msg, set_chat_name,
};
use crate::receive_imf::receive_imf;
use crate::stock_str::StockMessage;
@@ -482,7 +482,7 @@ mod tests {
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_try_load() {
async fn test_try_load() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let chat_id1 = create_group(bob, "a chat").await.unwrap();
@@ -552,6 +552,15 @@ mod tests {
.await
.unwrap();
assert_eq!(chats.len(), 1);
let chat_id = create_group(bob, "Δ-chat").await.unwrap();
let chats = Chatlist::try_load(bob, 0, Some("δ"), None).await?;
assert_eq!(chats.len(), 1);
assert_eq!(chats.ids[0].0, chat_id);
set_chat_name(bob, chat_id, "abcδe").await?;
let chats = Chatlist::try_load(bob, 0, Some("Δ"), None).await?;
assert_eq!(chats.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -13,15 +13,14 @@ use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
use tokio::fs;
use crate::blob::BlobObject;
use crate::configure::EnteredLoginParam;
use crate::context::Context;
use crate::events::EventType;
use crate::log::LogExt;
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
use crate::provider::{Provider, get_provider_by_id};
use crate::provider::Provider;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::get_abs_path;
use crate::transport::ConfiguredLoginParam;
use crate::transport::{ConfiguredLoginParam, add_pseudo_transport, send_sync_transports};
use crate::{constants, stats};
/// The available configuration keys.
@@ -144,11 +143,11 @@ pub enum Config {
/// Send BCC copy to self.
///
/// Should be enabled for multidevice setups.
/// Default is 0 for chatmail accounts, 1 otherwise.
/// Should be enabled for multi-device setups.
///
/// This is automatically enabled when importing/exporting a backup,
/// setting up a second device, or receiving a sync message.
#[strum(props(default = "0"))]
BccSelf,
/// True if Message Delivery Notifications (read receipts) should
@@ -168,10 +167,6 @@ pub enum Config {
#[strum(props(default = "0"))]
OnlyFetchMvbox,
/// Whether to show classic emails or only chat messages.
#[strum(props(default = "2"))] // also change ShowEmails.default() on changes
ShowEmails,
/// Quality of the media files to send.
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
MediaQuality,
@@ -204,7 +199,7 @@ pub enum Config {
/// `ProviderOptions::delete_to_trash`.
DeleteToTrash,
/// The primary email address. Also see `SecondaryAddrs`.
/// The primary email address.
ConfiguredAddr,
/// List of configured IMAP servers as a JSON array.
@@ -306,10 +301,6 @@ pub enum Config {
/// Meant to help profile owner to differ between profiles with similar names.
PrivateTag,
/// All secondary self addresses separated by spaces
/// (`addr1@example.org addr2@example.org addr3@example.org`)
SecondaryAddrs,
/// Read-only core version string.
#[strum(serialize = "sys.version")]
SysVersion,
@@ -438,8 +429,19 @@ pub enum Config {
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
/// Enables running test hooks, e.g. see `InnerContext::pre_encrypt_mime_hook`.
/// This way is better than conditional compilation, i.e. `#[cfg(test)]`, because tests not
/// using this still run unmodified code.
TestHooks,
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
FailOnReceivingFullMsg,
/// Enable composing emails with Header Protection as defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for Cryptographically
/// Protected Email".
#[strum(props(default = "1"))]
StdHeaderProtectionComposing,
}
impl Config {
@@ -458,7 +460,6 @@ impl Config {
Self::Displayname
| Self::MdnsEnabled
| Self::MvboxMove
| Self::ShowEmails
| Self::Selfavatar
| Self::Selfstatus,
)
@@ -466,7 +467,10 @@ impl Config {
/// Whether the config option needs an IO scheduler restart to take effect.
pub(crate) fn needs_io_restart(&self) -> bool {
matches!(self, Config::MvboxMove | Config::OnlyFetchMvbox)
matches!(
self,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr
)
}
}
@@ -495,7 +499,7 @@ impl Context {
.into_owned()
})
}
Config::SysVersion => Some((*constants::DC_VERSION_STR).clone()),
Config::SysVersion => Some(constants::DC_VERSION_STR.to_string()),
Config::SysMsgsizeMaxRecommended => Some(format!("{RECOMMENDED_FILE_SIZE}")),
Config::SysConfigKeys => Some(get_config_keys_string()),
_ => self.sql.get_raw_config(key.as_ref()).await?,
@@ -512,10 +516,6 @@ impl Context {
// Default values
let val = match key {
Config::BccSelf => match Box::pin(self.is_chatmail()).await? {
false => Some("1".to_string()),
true => Some("0".to_string()),
},
Config::ConfiguredInboxFolder => Some("INBOX".to_string()),
Config::DeleteServerAfter => {
match !Box::pin(self.get_config_bool(Config::BccSelf)).await?
@@ -636,15 +636,14 @@ impl Context {
Ok(val)
}
/// Gets the configured provider, as saved in the `configured_provider` value.
/// Gets the configured provider.
///
/// The provider is determined by `get_provider_info()` during configuration and then saved
/// to the db in `param.save_to_database()`, together with all the other `configured_*` values.
/// The provider is determined by the current primary transport.
pub async fn get_configured_provider(&self) -> Result<Option<&'static Provider>> {
if let Some(cfg) = self.get_config(Config::ConfiguredProvider).await? {
return Ok(get_provider_by_id(&cfg));
}
Ok(None)
let provider = ConfiguredLoginParam::load(self)
.await?
.and_then(|(_transport_id, param)| param.provider);
Ok(provider)
}
/// Gets configured "delete_device_after" value.
@@ -665,7 +664,7 @@ impl Context {
Config::Selfavatar if value.is_empty() => None,
Config::Selfavatar => {
config_value = BlobObject::store_from_base64(self, value)?;
Some(config_value.as_str())
config_value.as_deref()
}
_ => Some(value),
};
@@ -706,6 +705,11 @@ impl Context {
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
@@ -791,19 +795,51 @@ impl Context {
.await?;
}
Config::ConfiguredAddr => {
if self.is_configured().await? {
bail!("Cannot change ConfiguredAddr");
}
if let Some(addr) = value {
let Some(addr) = value else {
bail!("Cannot unset configured_addr");
};
if !self.is_configured().await? {
info!(
self,
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
);
ConfiguredLoginParam::from_json(&format!(
r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#
))?
.save_to_transports_table(self, &EnteredLoginParam::default())
.await?;
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,),
)?;
// Clean up SMTP and IMAP APPEND queue.
//
// The messages in the queue have a different
// From address so we cannot send them over
// the new SMTP transport.
transaction.execute("DELETE FROM smtp", ())?;
transaction.execute("DELETE FROM imap_send", ())?;
Ok(())
})
.await?;
send_sync_transports(self).await?;
self.sql.uncache_raw_config("configured_addr").await;
}
}
_ => {
@@ -900,16 +936,6 @@ impl Context {
pub(crate) async fn set_primary_self_addr(&self, primary_new: &str) -> Result<()> {
self.quota.write().await.take();
// add old primary address (if exists) to secondary addresses
let mut secondary_addrs = self.get_all_self_addrs().await?;
// never store a primary address also as a secondary
secondary_addrs.retain(|a| !addr_cmp(a, primary_new));
self.set_config_internal(
Config::SecondaryAddrs,
Some(secondary_addrs.join(" ").as_str()),
)
.await?;
self.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(primary_new))
.await?;
@@ -927,14 +953,10 @@ impl Context {
/// Returns all secondary self addresses.
pub(crate) async fn get_secondary_self_addrs(&self) -> Result<Vec<String>> {
let secondary_addrs = self
.get_config(Config::SecondaryAddrs)
.await?
.unwrap_or_default();
Ok(secondary_addrs
.split_ascii_whitespace()
.map(|s| s.to_string())
.collect())
self.sql.query_map_vec("SELECT addr FROM transports WHERE addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')", (), |row| {
let addr: String = row.get(0)?;
Ok(addr)
}).await
}
/// Returns the primary self address.

View File

@@ -94,59 +94,6 @@ async fn test_set_config_bool() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_self_addrs() -> Result<()> {
let alice = TestContext::new_alice().await;
assert!(alice.is_self_addr("alice@example.org").await?);
assert_eq!(alice.get_all_self_addrs().await?, vec!["alice@example.org"]);
assert!(!alice.is_self_addr("alice@alice.com").await?);
// Test adding the same primary address
alice.set_primary_self_addr("alice@example.org").await?;
alice.set_primary_self_addr("Alice@Example.Org").await?;
assert_eq!(alice.get_all_self_addrs().await?, vec!["Alice@Example.Org"]);
// Test adding a new (primary) self address
// The address is trimmed during configure by `LoginParam::from_database()`,
// so `set_primary_self_addr()` doesn't have to trim it.
alice.set_primary_self_addr("Alice@alice.com").await?;
assert!(alice.is_self_addr("aliCe@example.org").await?);
assert!(alice.is_self_addr("alice@alice.com").await?);
assert_eq!(
alice.get_all_self_addrs().await?,
vec!["Alice@alice.com", "Alice@Example.Org"]
);
// Check that the entry is not duplicated
alice.set_primary_self_addr("alice@alice.com").await?;
alice.set_primary_self_addr("alice@alice.com").await?;
assert_eq!(
alice.get_all_self_addrs().await?,
vec!["alice@alice.com", "Alice@Example.Org"]
);
// Test switching back
alice.set_primary_self_addr("alice@example.org").await?;
assert_eq!(
alice.get_all_self_addrs().await?,
vec!["alice@example.org", "alice@alice.com"]
);
// Test setting a new primary self address, the previous self address
// should be kept as a secondary self address
alice.set_primary_self_addr("alice@alice.xyz").await?;
assert_eq!(
alice.get_all_self_addrs().await?,
vec!["alice@alice.xyz", "alice@example.org", "alice@alice.com"]
);
assert!(alice.is_self_addr("alice@example.org").await?);
assert!(alice.is_self_addr("alice@alice.com").await?);
assert!(alice.is_self_addr("Alice@alice.xyz").await?);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mdns_default_behaviour() -> Result<()> {
let t = &TestContext::new_alice().await;
@@ -218,12 +165,10 @@ async fn test_sync() -> Result<()> {
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::MdnsEnabled).await?, false);
for key in [Config::ShowEmails, Config::MvboxMove] {
let val = alice0.get_config_bool(key).await?;
alice0.set_config_bool(key, !val).await?;
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(key).await?, !val);
}
let val = alice0.get_config_bool(Config::MvboxMove).await?;
alice0.set_config_bool(Config::MvboxMove, !val).await?;
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::MvboxMove).await?, !val);
// `Config::SyncMsgs` mustn't be synced.
alice0.set_config_bool(Config::SyncMsgs, false).await?;
@@ -278,7 +223,6 @@ async fn test_sync() -> Result<()> {
Ok(())
}
/// Sync message mustn't be sent if self-{status,avatar} is changed by a self-sent message.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_sync_on_self_sent_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -288,7 +232,7 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
let status = "Synced via usual message";
let status = "Sent via usual message";
alice0.set_config(Config::Selfstatus, Some(status)).await?;
alice0.send_sync_msg().await?;
alice0.pop_sent_sync_msg().await;
@@ -297,7 +241,7 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
tcm.send_recv(alice0, alice1, "hi Alice!").await;
assert_eq!(
alice1.get_config(Config::Selfstatus).await?,
Some(status.to_string())
Some(status1.to_string())
);
sync(alice1, alice0).await;
assert_eq!(
@@ -328,7 +272,7 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
alice1
.get_config(Config::Selfavatar)
.await?
.filter(|path| path.ends_with(".png"))
.filter(|path| path.ends_with(".jpg"))
.is_some()
);
sync(alice1, alice0).await;

View File

@@ -27,7 +27,7 @@ use crate::config::{self, Config};
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::imap::Imap;
use crate::log::{LogExt, warn};
use crate::log::warn;
use crate::login_param::EnteredCertificateChecks;
pub use crate::login_param::EnteredLoginParam;
use crate::message::Message;
@@ -40,11 +40,14 @@ use crate::sync::Sync::*;
use crate::tools::time;
use crate::transport::{
ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam,
ConnectionCandidate,
ConnectionCandidate, send_sync_transports,
};
use crate::{EventType, stock_str};
use crate::{chat, provider};
use deltachat_contact_tools::addr_cmp;
/// Maximum number of relays
/// see <https://github.com/chatmail/core/issues/7608>
pub(crate) const MAX_TRANSPORT_RELAYS: usize = 5;
macro_rules! progress {
($context:tt, $progress:expr, $comment:expr) => {
@@ -130,12 +133,6 @@ impl Context {
"cannot configure, database not opened."
);
param.addr = addr_normalize(&param.addr);
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
if self.is_configured().await? && !addr_cmp(&old_addr.unwrap_or_default(), &param.addr) {
let error_msg = "Changing your email address is not supported right now. Check back in a few months!";
progress!(self, 0, Some(error_msg.to_string()));
bail!(error_msg);
}
let cancel_channel = self.alloc_ongoing().await?;
let res = self
@@ -204,23 +201,120 @@ impl Context {
Ok(transports)
}
/// Returns the number of configured transports.
pub async fn count_transports(&self) -> Result<usize> {
self.sql.count("SELECT COUNT(*) FROM transports", ()).await
}
/// Removes the transport with the specified email address
/// (i.e. [EnteredLoginParam::addr]).
#[expect(clippy::unused_async)]
pub async fn delete_transport(&self, _addr: &str) -> Result<()> {
bail!(
"Adding and removing additional transports is not supported yet. Check back in a few months!"
)
pub async fn delete_transport(&self, addr: &str) -> Result<()> {
let now = time();
self.sql
.transaction(|transaction| {
let primary_addr = transaction.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)?;
if primary_addr == addr {
bail!("Cannot delete primary transport");
}
let (transport_id, add_timestamp) = transaction.query_row(
"DELETE FROM transports WHERE addr=? RETURNING id, add_timestamp",
(addr,),
|row| {
let id: u32 = row.get(0)?;
let add_timestamp: i64 = row.get(1)?;
Ok((id, add_timestamp))
},
)?;
transaction.execute("DELETE FROM imap WHERE transport_id=?", (transport_id,))?;
transaction.execute(
"DELETE FROM imap_sync WHERE transport_id=?",
(transport_id,),
)?;
// Removal timestamp should not be lower than addition timestamp
// to be accepted by other devices when synced.
let remove_timestamp = std::cmp::max(now, add_timestamp);
transaction.execute(
"INSERT INTO removed_transports (addr, remove_timestamp)
VALUES (?, ?)
ON CONFLICT (addr)
DO UPDATE SET remove_timestamp = excluded.remove_timestamp",
(addr, remove_timestamp),
)?;
Ok(())
})
.await?;
send_sync_transports(self).await?;
Ok(())
}
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
info!(self, "Configure ...");
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
let provider = configure(self, param).await?;
if old_addr.is_some()
&& !self
.sql
.exists(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(&param.addr,),
)
.await?
{
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
bail!(
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
);
}
if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") {
bail!(
"To use additional relays, disable the legacy option \"Settings / Advanced / Only Fetch from DeltaChat Folder\"."
);
}
if self
.sql
.count("SELECT COUNT(*) FROM transports", ())
.await?
>= MAX_TRANSPORT_RELAYS
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
)
}
}
let provider = match configure(self, param).await {
Err(error) => {
// Log entered and actual params
let configured_param = get_configured_param(self, param).await;
warn!(
self,
"configure failed: Entered params: {}. Used params: {}. Error: {error}.",
param.to_string(),
configured_param
.map(|param| param.to_string())
.unwrap_or("error".to_owned())
);
return Err(error);
}
Ok(provider) => provider,
};
self.set_config_internal(Config::NotifyAboutWrongPw, Some("1"))
.await?;
on_configure_completed(self, provider, old_addr).await?;
on_configure_completed(self, provider).await?;
Ok(())
}
}
@@ -228,7 +322,6 @@ impl Context {
async fn on_configure_completed(
context: &Context,
provider: Option<&'static Provider>,
old_addr: Option<String>,
) -> Result<()> {
if let Some(provider) = provider {
if let Some(config_defaults) = provider.config_defaults {
@@ -258,21 +351,6 @@ async fn on_configure_completed(
}
}
if let Some(new_addr) = context.get_config(Config::ConfiguredAddr).await? {
if let Some(old_addr) = old_addr {
if !addr_cmp(&new_addr, &old_addr) {
let mut msg = Message::new_text(
stock_str::aeap_explanation_and_link(context, &old_addr, &new_addr).await,
);
chat::add_device_msg(context, None, Some(&mut msg))
.await
.context("Cannot add AEAP explanation")
.log_err(context)
.ok();
}
}
}
Ok(())
}
@@ -504,71 +582,40 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
// Configure IMAP
let transport_id = 0;
let (_s, r) = async_channel::bounded(1);
let mut imap = Imap::new(
configured_param.imap.clone(),
configured_param.imap_password.clone(),
proxy_config,
&configured_param.addr,
strict_tls,
configured_param.oauth2,
r,
);
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
let configuring = true;
let mut imap_session = match imap.connect(ctx, configuring).await {
Ok(session) => session,
Err(err) => bail!(
if let Err(err) = imap.connect(ctx, configuring).await {
bail!(
"{}",
nicer_configuration_error(ctx, format!("{err:#}")).await
),
);
};
progress!(ctx, 850);
// Wait for SMTP configuration
smtp_config_task.await.unwrap()?;
smtp_config_task.await??;
progress!(ctx, 900);
let is_chatmail = match ctx.get_config_bool(Config::FixIsChatmail).await? {
false => {
let is_chatmail = imap_session.is_chatmail();
ctx.set_config(
Config::IsChatmail,
Some(match is_chatmail {
false => "0",
true => "1",
}),
)
.await?;
is_chatmail
}
true => ctx.get_config_bool(Config::IsChatmail).await?,
};
if is_chatmail {
ctx.set_config(Config::MvboxMove, Some("0")).await?;
ctx.set_config(Config::OnlyFetchMvbox, None).await?;
ctx.set_config(Config::ShowEmails, None).await?;
let is_configured = ctx.is_configured().await?;
if !is_configured {
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
ctx.sql.set_raw_config("only_fetch_mvbox", None).await?;
}
let create_mvbox = !is_chatmail;
imap.configure_folders(ctx, &mut imap_session, create_mvbox)
.await?;
let create = true;
imap_session
.select_with_uidvalidity(ctx, "INBOX", create)
.await
.context("could not read INBOX status")?;
drop(imap);
progress!(ctx, 910);
let provider = configured_param.provider;
configured_param
.save_to_transports_table(ctx, param)
.clone()
.save_to_transports_table(ctx, param, time())
.await?;
send_sync_transports(ctx).await?;
ctx.set_config_internal(Config::ConfiguredTimestamp, Some(&time().to_string()))
.await?;

View File

@@ -154,10 +154,10 @@ fn parse_xml_reader<B: BufRead>(
if let Some(incoming_server) = parse_server(reader, event)? {
incoming_servers.push(incoming_server);
}
} else if tag == "outgoingserver" {
if let Some(outgoing_server) = parse_server(reader, event)? {
outgoing_servers.push(outgoing_server);
}
} else if tag == "outgoingserver"
&& let Some(outgoing_server) = parse_server(reader, event)?
{
outgoing_servers.push(outgoing_server);
}
}
Event::Eof => break,

View File

@@ -2,16 +2,13 @@
#![allow(missing_docs)]
use std::sync::LazyLock;
use deltachat_derive::{FromSql, ToSql};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
use serde::{Deserialize, Serialize};
use crate::chat::ChatId;
pub static DC_VERSION_STR: LazyLock<String> =
LazyLock::new(|| env!("CARGO_PKG_VERSION").to_string());
pub static DC_VERSION_STR: &str = env!("CARGO_PKG_VERSION");
/// Set of characters to percent-encode in email addresses and names.
pub(crate) const NON_ALPHANUMERIC_WITHOUT_DOT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'.');
@@ -39,17 +36,6 @@ pub enum Blocked {
Request = 2,
}
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
#[repr(u8)]
pub enum ShowEmails {
Off = 0,
AcceptedContacts = 1,
#[default] // also change Config.ShowEmails props(default) on changes
All = 2,
}
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
@@ -223,6 +209,9 @@ pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
// `max_smtp_rcpt_to` in the provider db.
pub(crate) const DEFAULT_MAX_SMTP_RCPT_TO: usize = 50;
/// Same as `DEFAULT_MAX_SMTP_RCPT_TO`, but for chatmail relays.
pub(crate) const DEFAULT_CHATMAIL_MAX_SMTP_RCPT_TO: usize = 999;
/// 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
@@ -277,18 +266,6 @@ mod tests {
assert_eq!(Chattype::OutBroadcast, Chattype::from_i32(160).unwrap());
}
#[test]
fn test_showemails_values() {
// values may be written to disk and must not change
assert_eq!(ShowEmails::All, ShowEmails::default());
assert_eq!(ShowEmails::Off, ShowEmails::from_i32(0).unwrap());
assert_eq!(
ShowEmails::AcceptedContacts,
ShowEmails::from_i32(1).unwrap()
);
assert_eq!(ShowEmails::All, ShowEmails::from_i32(2).unwrap());
}
#[test]
fn test_blocked_values() {
// values may be written to disk and must not change

View File

@@ -36,7 +36,7 @@ use crate::message::MessageState;
use crate::mimeparser::AvatarAction;
use crate::param::{Param, Params};
use crate::sync::{self, Sync::*};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, time, to_lowercase};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str};
/// Time during which a contact is considered as seen recently.
@@ -115,9 +115,23 @@ impl ContactId {
let row = context
.sql
.transaction(|transaction| {
let authname;
let name_or_authname = if !name.is_empty() {
name
} else {
authname = transaction.query_row(
"SELECT authname FROM contacts WHERE id=?",
(self,),
|row| {
let authname: String = row.get(0)?;
Ok(authname)
},
)?;
&authname
};
let is_changed = transaction.execute(
"UPDATE contacts SET name=?1 WHERE id=?2 AND name!=?1",
(name, self),
"UPDATE contacts SET name=?1, name_normalized=?2 WHERE id=?3 AND name!=?1",
(name, normalize_text(name_or_authname), self),
)? > 0;
if is_changed {
update_chat_names(context, transaction, self)?;
@@ -130,35 +144,37 @@ impl ContactId {
Ok((addr, fingerprint))
},
)?;
context.emit_event(EventType::ContactsChanged(Some(self)));
Ok(Some((addr, fingerprint)))
} else {
Ok(None)
}
})
.await?;
if row.is_some() {
context.emit_event(EventType::ContactsChanged(Some(self)));
}
if sync.into() {
if let Some((addr, fingerprint)) = row {
if fingerprint.is_empty() {
chat::sync(
context,
chat::SyncId::ContactAddr(addr),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
} else {
chat::sync(
context,
chat::SyncId::ContactFingerprint(fingerprint),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
}
if sync.into()
&& let Some((addr, fingerprint)) = row
{
if fingerprint.is_empty() {
chat::sync(
context,
chat::SyncId::ContactAddr(addr),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
} else {
chat::sync(
context,
chat::SyncId::ContactFingerprint(fingerprint),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
}
}
Ok(())
@@ -369,38 +385,33 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
return Ok(id);
}
let path = match &contact.profile_image {
Some(image) => match BlobObject::store_from_base64(context, image) {
Err(e) => {
Some(image) => match BlobObject::store_from_base64(context, image)? {
None => {
warn!(
context,
"import_vcard_contact: Could not decode and save avatar for {}: {e:#}.",
contact.addr
"import_vcard_contact: Could not decode avatar for {}.", contact.addr
);
None
}
Ok(path) => Some(path),
Some(path) => Some(path),
},
None => None,
};
if let Some(path) = path {
// Currently this value doesn't matter as we don't import the contact of self.
let was_encrypted = false;
if let Err(e) =
set_profile_image(context, id, &AvatarAction::Change(path), was_encrypted).await
{
warn!(
context,
"import_vcard_contact: Could not set avatar for {}: {e:#}.", contact.addr
);
}
if let Some(path) = path
&& let Err(e) = set_profile_image(context, id, &AvatarAction::Change(path)).await
{
warn!(
context,
"import_vcard_contact: Could not set avatar for {}: {e:#}.", contact.addr
);
}
if let Some(biography) = &contact.biography {
if let Err(e) = set_status(context, id, biography.to_owned(), false, false).await {
warn!(
context,
"import_vcard_contact: Could not set biography for {}: {e:#}.", contact.addr
);
}
if let Some(biography) = &contact.biography
&& let Err(e) = set_status(context, id, biography.to_owned()).await
{
warn!(
context,
"import_vcard_contact: Could not set biography for {}: {e:#}.", contact.addr
);
}
Ok(id)
}
@@ -970,11 +981,22 @@ impl Contact {
} else {
row_name
};
let new_authname = if update_authname {
name.to_string()
} else {
row_authname
};
transaction.execute(
"UPDATE contacts SET name=?, addr=?, origin=?, authname=? WHERE id=?;",
"UPDATE contacts SET name=?, name_normalized=?, addr=?, origin=?, authname=? WHERE id=?",
(
new_name,
&new_name,
normalize_text(
if !new_name.is_empty() {
&new_name
} else {
&new_authname
}),
if update_addr {
addr.to_string()
} else {
@@ -985,11 +1007,7 @@ impl Contact {
} else {
row_origin
},
if update_authname {
name.to_string()
} else {
row_authname
},
&new_authname,
row_id,
),
)?;
@@ -1001,18 +1019,18 @@ impl Contact {
sth_modified = Modifier::Modified;
}
} else {
let update_name = manual;
let update_authname = !manual;
transaction.execute(
"INSERT INTO contacts (name, addr, fingerprint, origin, authname)
VALUES (?, ?, ?, ?, ?);",
"
INSERT INTO contacts (name, name_normalized, addr, fingerprint, origin, authname)
VALUES (?, ?, ?, ?, ?, ?)
",
(
if update_name { &name } else { "" },
if manual { &name } else { "" },
normalize_text(&name),
&addr,
fingerprint,
origin,
if update_authname { &name } else { "" },
if manual { "" } else { &name },
),
)?;
@@ -1115,23 +1133,26 @@ impl Contact {
Origin::IncomingReplyTo
};
if query.is_some() {
let s3str_like_cmd = format!("%{}%", query.unwrap_or(""));
let s3str_like_cmd = format!("%{}%", query.unwrap_or("").to_lowercase());
context
.sql
.query_map(
"SELECT c.id, c.addr FROM contacts c
WHERE c.id>?
AND (c.fingerprint='')=?
AND c.origin>=? \
AND c.blocked=0 \
AND (iif(c.name='',c.authname,c.name) LIKE ? OR c.addr LIKE ?) \
ORDER BY c.last_seen DESC, c.id DESC;",
"
SELECT c.id, c.addr FROM contacts c
WHERE c.id>?
AND (c.fingerprint='')=?
AND c.origin>=?
AND c.blocked=0
AND (IFNULL(c.name_normalized,IIF(c.name='',c.authname,c.name)) LIKE ? OR c.addr LIKE ?)
ORDER BY c.origin>=? DESC, c.last_seen DESC, c.id DESC
",
(
ContactId::LAST_SPECIAL,
flag_address,
minimal_origin,
&s3str_like_cmd,
&s3str_like_cmd,
Origin::CreateChat,
),
|row| {
let id: ContactId = row.get(0)?;
@@ -1181,8 +1202,13 @@ impl Contact {
AND (fingerprint='')=?
AND origin>=?
AND blocked=0
ORDER BY last_seen DESC, id DESC;",
(ContactId::LAST_SPECIAL, flag_address, minimal_origin),
ORDER BY origin>=? DESC, last_seen DESC, id DESC",
(
ContactId::LAST_SPECIAL,
flag_address,
minimal_origin,
Origin::CreateChat,
),
|row| {
let id: ContactId = row.get(0)?;
let addr: String = row.get(1)?;
@@ -1252,8 +1278,18 @@ impl Contact {
};
// Always do an update in case the blocking is reset or name is changed.
transaction.execute(
"UPDATE contacts SET name=?, origin=?, blocked=1, fingerprint=? WHERE addr=?",
(&name, Origin::MailinglistAddress, fingerprint, &grpid),
"
UPDATE contacts
SET name=?, name_normalized=IIF(?1='',name_normalized,?), origin=?, blocked=1, fingerprint=?
WHERE addr=?
",
(
&name,
normalize_text(&name),
Origin::MailinglistAddress,
fingerprint,
&grpid,
),
)?;
}
Ok(())
@@ -1508,18 +1544,6 @@ impl Contact {
&self.addr
}
/// Get authorized name or address.
///
/// This string is suitable for sending over email
/// as it does not leak the locally set name.
pub(crate) fn get_authname_or_addr(&self) -> String {
if !self.authname.is_empty() {
(&self.authname).into()
} else {
(&self.addr).into()
}
}
/// Get a summary of name and address.
///
/// The returned string is either "Name (email@domain.com)" or just
@@ -1565,20 +1589,33 @@ impl Contact {
if show_fallback_icon && !self.id.is_special() && !self.is_key_contact() {
return Ok(Some(chat::get_unencrypted_icon(context).await?));
}
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
if !image_rel.is_empty() {
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
}
if let Some(image_rel) = self.param.get(Param::ProfileImage)
&& !image_rel.is_empty()
{
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
}
Ok(None)
}
/// Returns a color for the contact.
/// See [`self::get_color`].
/// For self-contact this returns gray if own keypair doesn't exist yet.
/// See also [`self::get_color`].
pub fn get_color(&self) -> u32 {
get_color(self.id == ContactId::SELF, &self.addr, &self.fingerprint())
}
/// Returns a color for the contact.
/// Ensures that the color isn't gray. For self-contact this generates own keypair if it doesn't
/// exist yet.
/// See also [`self::get_color`].
pub async fn get_or_gen_color(&self, context: &Context) -> Result<u32> {
let mut fpr = self.fingerprint();
if fpr.is_none() && self.id == ContactId::SELF {
fpr = Some(load_self_public_key(context).await?.dc_fingerprint());
}
Ok(get_color(self.id == ContactId::SELF, &self.addr, &fpr))
}
/// Gets the contact's status.
///
/// Status is the last signature received in a message from this contact.
@@ -1727,8 +1764,8 @@ fn update_chat_names(
};
let count = transaction.execute(
"UPDATE chats SET name=?1 WHERE id=?2 AND name!=?1",
(chat_name, chat_id),
"UPDATE chats SET name=?1, name_normalized=?2 WHERE id=?3 AND name!=?1",
(&chat_name, normalize_text(&chat_name), chat_id),
)?;
if count > 0 {
@@ -1788,10 +1825,11 @@ WHERE type=? AND id IN (
// also unblock mailinglist
// if the contact is a mailinglist address explicitly created to allow unblocking
if !new_blocking && contact.origin == Origin::MailinglistAddress {
if let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await? {
chat_id.unblock_ex(context, Nosync).await?;
}
if !new_blocking
&& contact.origin == Origin::MailinglistAddress
&& let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await?
{
chat_id.unblock_ex(context, Nosync).await?;
}
if sync.into() {
@@ -1821,25 +1859,19 @@ WHERE type=? AND id IN (
/// The given profile image is expected to be already in the blob directory
/// as profile images can be set only by receiving messages, this should be always the case, however.
///
/// For contact SELF, the image is not saved in the contact-database but as Config::Selfavatar;
/// this typically happens if we see message with our own profile image.
/// For contact SELF, the image is not saved in the contact-database but as Config::Selfavatar.
pub(crate) async fn set_profile_image(
context: &Context,
contact_id: ContactId,
profile_image: &AvatarAction,
was_encrypted: bool,
) -> Result<()> {
let mut contact = Contact::get_by_id(context, contact_id).await?;
let changed = match profile_image {
AvatarAction::Change(profile_image) => {
if contact_id == ContactId::SELF {
if was_encrypted {
context
.set_config_ex(Nosync, Config::Selfavatar, Some(profile_image))
.await?;
} else {
info!(context, "Do not use unencrypted selfavatar.");
}
context
.set_config_ex(Nosync, Config::Selfavatar, Some(profile_image))
.await?;
} else {
contact.param.set(Param::ProfileImage, profile_image);
}
@@ -1847,13 +1879,9 @@ pub(crate) async fn set_profile_image(
}
AvatarAction::Delete => {
if contact_id == ContactId::SELF {
if was_encrypted {
context
.set_config_ex(Nosync, Config::Selfavatar, None)
.await?;
} else {
info!(context, "Do not use unencrypted selfavatar deletion.");
}
context
.set_config_ex(Nosync, Config::Selfavatar, None)
.await?;
} else {
contact.param.remove(Param::ProfileImage);
}
@@ -1870,22 +1898,16 @@ pub(crate) async fn set_profile_image(
/// Sets contact status.
///
/// For contact SELF, the status is not saved in the contact table, but as Config::Selfstatus. This
/// is only done if message is sent from Delta Chat and it is encrypted, to synchronize signature
/// between Delta Chat devices.
/// For contact SELF, the status is not saved in the contact table, but as Config::Selfstatus.
pub(crate) async fn set_status(
context: &Context,
contact_id: ContactId,
status: String,
encrypted: bool,
has_chat_version: bool,
) -> Result<()> {
if contact_id == ContactId::SELF {
if encrypted && has_chat_version {
context
.set_config_ex(Nosync, Config::Selfstatus, Some(&status))
.await?;
}
context
.set_config_ex(Nosync, Config::Selfstatus, Some(&status))
.await?;
} else {
let mut contact = Contact::get_by_id(context, contact_id).await?;

View File

@@ -4,8 +4,7 @@ use super::*;
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync};
#[test]
fn test_contact_id_values() {
@@ -61,16 +60,16 @@ async fn test_get_contacts() -> Result<()> {
let context = tcm.bob().await;
let alice = tcm.alice().await;
alice
.set_config(Config::Displayname, Some("MyName"))
.set_config(Config::Displayname, Some("MyNameIsΔ"))
.await?;
// Alice is not in the contacts yet.
let contacts = Contact::get_all(&context.ctx, 0, Some("Alice")).await?;
assert_eq!(contacts.len(), 0);
let contacts = Contact::get_all(&context.ctx, 0, Some("MyName")).await?;
let contacts = Contact::get_all(&context.ctx, 0, Some("MyNameIsΔ")).await?;
assert_eq!(contacts.len(), 0);
let claire_id = Contact::create(&context, "someone", "claire@example.org").await?;
let claire_id = Contact::create(&context, "Δ-someone", "claire@example.org").await?;
let dave_id = Contact::create(&context, "", "dave@example.org").await?;
let id = context.add_or_lookup_contact_id(&alice).await;
@@ -78,8 +77,8 @@ async fn test_get_contacts() -> Result<()> {
let contact = Contact::get_by_id(&context, id).await.unwrap();
assert_eq!(contact.get_name(), "");
assert_eq!(contact.get_authname(), "MyName");
assert_eq!(contact.get_display_name(), "MyName");
assert_eq!(contact.get_authname(), "MyNameIsΔ");
assert_eq!(contact.get_display_name(), "MyNameIsΔ");
// Search by name.
let contacts = Contact::get_all(&context, 0, Some("myname")).await?;
@@ -94,12 +93,12 @@ async fn test_get_contacts() -> Result<()> {
let contacts = Contact::get_all(&context, 0, Some("Foobar")).await?;
assert_eq!(contacts.len(), 0);
// Set Alice name to "someone" manually.
id.set_name(&context, "someone").await?;
// Set Alice name manually.
id.set_name(&context, "Δ-someone").await?;
let contact = Contact::get_by_id(&context.ctx, id).await.unwrap();
assert_eq!(contact.get_name(), "someone");
assert_eq!(contact.get_authname(), "MyName");
assert_eq!(contact.get_display_name(), "someone");
assert_eq!(contact.get_name(), "Δ-someone");
assert_eq!(contact.get_authname(), "MyNameIsΔ");
assert_eq!(contact.get_display_name(), "Δ-someone");
// Not searchable by authname, because it is not displayed.
let contacts = Contact::get_all(&context, 0, Some("MyName")).await?;
@@ -109,7 +108,9 @@ async fn test_get_contacts() -> Result<()> {
info!(&context, "add_self={add_self}");
// Search key-contacts by display name (same as manually set name).
let contacts = Contact::get_all(&context.ctx, add_self, Some("someone")).await?;
let contacts = Contact::get_all(&context.ctx, add_self, Some("Δ-someone")).await?;
assert_eq!(contacts, vec![id]);
let contacts = Contact::get_all(&context.ctx, add_self, Some("δ-someon")).await?;
assert_eq!(contacts, vec![id]);
// Get all key-contacts.
@@ -121,7 +122,7 @@ async fn test_get_contacts() -> Result<()> {
}
// Search address-contacts by display name.
let contacts = Contact::get_all(&context, constants::DC_GCL_ADDRESS, Some("someone")).await?;
let contacts = Contact::get_all(&context, constants::DC_GCL_ADDRESS, Some("Δ-someone")).await?;
assert_eq!(contacts, vec![claire_id]);
// Get all address-contacts. Newer contacts go first.
@@ -135,6 +136,16 @@ async fn test_get_contacts() -> Result<()> {
.await?;
assert_eq!(contacts, vec![dave_id, claire_id, ContactId::SELF]);
// Reset the user-provided name for Alice.
id.set_name(&context, "").await?;
let contact = Contact::get_by_id(&context.ctx, id).await.unwrap();
assert_eq!(contact.get_name(), "");
assert_eq!(contact.get_authname(), "MyNameIsΔ");
assert_eq!(contact.get_display_name(), "MyNameIsΔ");
let contacts = Contact::get_all(&context, 0, Some("MyName")).await?;
assert_eq!(contacts.len(), 1);
let contacts = Contact::get_all(&context, 0, Some("δ")).await?;
assert_eq!(contacts.len(), 1);
Ok(())
}
@@ -775,16 +786,21 @@ async fn test_contact_get_color() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_self_color_vs_key() -> Result<()> {
async fn test_self_color() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
t.configure_addr("alice@example.org").await;
assert!(t.is_configured().await?);
let color = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
let self_contact = Contact::get_by_id(t, ContactId::SELF).await?;
let color = self_contact.get_color();
assert_eq!(color, 0x808080);
get_securejoin_qr(t, None).await?;
let color1 = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
assert_ne!(color1, color);
let color = self_contact.get_or_gen_color(t).await?;
assert_ne!(color, 0x808080);
let color1 = self_contact.get_or_gen_color(t).await?;
assert_eq!(color1, color);
let bob = &tcm.bob().await;
assert_eq!(bob.add_or_lookup_contact(t).await.get_color(), color);
Ok(())
}
@@ -846,8 +862,7 @@ CCCB 5AA9 F6E1 141C 9431
Ok(())
}
/// Tests that status is synchronized when sending encrypted BCC-self messages and not
/// synchronized when the message is not encrypted.
/// Tests that self-status is not synchronized from outgoing messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_synchronize_status() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -866,21 +881,12 @@ async fn test_synchronize_status() -> Result<()> {
.await?;
let chat = alice1.create_email_chat(bob).await;
// Alice sends a message to Bob from the first device.
// Alice sends an unencrypted message to Bob from the first device.
send_text_msg(alice1, chat.id, "Hello".to_string()).await?;
let sent_msg = alice1.pop_sent_msg().await;
// Message is not encrypted.
let message = sent_msg.load_from_db().await;
assert!(!message.get_showpadlock());
// Alice's second devices receives a copy of outgoing message.
alice2.recv_msg(&sent_msg).await;
// Bob receives message.
bob.recv_msg(&sent_msg).await;
// Message was not encrypted, so status is not copied.
assert_eq!(alice2.get_config(Config::Selfstatus).await?, default_status);
// Alice sends encrypted message.
@@ -888,17 +894,9 @@ async fn test_synchronize_status() -> Result<()> {
send_text_msg(alice1, chat.id, "Hello".to_string()).await?;
let sent_msg = alice1.pop_sent_msg().await;
// Second message is encrypted.
let message = sent_msg.load_from_db().await;
assert!(message.get_showpadlock());
// Alice's second devices receives a copy of second outgoing message.
alice2.recv_msg(&sent_msg).await;
assert_eq!(
alice2.get_config(Config::Selfstatus).await?,
Some("New status".to_string())
);
assert_eq!(alice2.get_config(Config::Selfstatus).await?, default_status);
Ok(())
}
@@ -911,9 +909,9 @@ async fn test_selfavatar_changed_event() -> Result<()> {
// Alice has two devices.
let alice1 = &tcm.alice().await;
let alice2 = &tcm.alice().await;
// Bob has one device.
let bob = &tcm.bob().await;
for a in [alice1, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
assert_eq!(alice1.get_config(Config::Selfavatar).await?, None);
@@ -929,17 +927,7 @@ async fn test_selfavatar_changed_event() -> Result<()> {
.get_matching(|e| matches!(e, EventType::SelfavatarChanged))
.await;
// Alice sends a message.
let alice1_chat_id = alice1.create_chat(bob).await.id;
send_text_msg(alice1, alice1_chat_id, "Hello".to_string()).await?;
let sent_msg = alice1.pop_sent_msg().await;
// The message is encrypted.
let message = sent_msg.load_from_db().await;
assert!(message.get_showpadlock());
// Alice's second device receives a copy of the outgoing message.
alice2.recv_msg(&sent_msg).await;
sync(alice1, alice2).await;
// Alice's second device applies the selfavatar.
assert!(alice2.get_config(Config::Selfavatar).await?.is_some());

View File

@@ -5,7 +5,7 @@ use std::ffi::OsString;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
use anyhow::{Context as _, Result, bail, ensure};
@@ -23,7 +23,6 @@ use crate::imap::{FolderMeaning, Imap, ServerMetadata};
use crate::key::self_fingerprint;
use crate::log::warn;
use crate::logged_debug_assert;
use crate::login_param::EnteredLoginParam;
use crate::message::{self, MessageState, MsgId};
use crate::net::tls::TlsSessionStore;
use crate::peer_channels::Iroh;
@@ -46,7 +45,7 @@ use crate::{chatlist_events, stats};
///
/// # Examples
///
/// Creating a new unencrypted database:
/// Creating a new database:
///
/// ```
/// # let rt = tokio::runtime::Runtime::new().unwrap();
@@ -61,24 +60,6 @@ use crate::{chatlist_events, stats};
/// drop(context);
/// # });
/// ```
///
/// To use an encrypted database provide a password. If the database does not yet exist it
/// will be created:
///
/// ```
/// # let rt = tokio::runtime::Runtime::new().unwrap();
/// # rt.block_on(async move {
/// use deltachat::context::ContextBuilder;
///
/// let dir = tempfile::tempdir().unwrap();
/// let context = ContextBuilder::new(dir.path().join("db"))
/// .with_password("secret".into())
/// .open()
/// .await
/// .unwrap();
/// drop(context);
/// # });
/// ```
#[derive(Clone, Debug)]
pub struct ContextBuilder {
dbfile: PathBuf,
@@ -150,9 +131,13 @@ impl ContextBuilder {
}
/// 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
@@ -180,7 +165,7 @@ impl ContextBuilder {
/// Builds the [`Context`] and opens it.
///
/// Returns error if context cannot be opened with the given passphrase.
/// 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?;
@@ -215,6 +200,25 @@ impl Deref for Context {
}
}
/// A weak reference to a [`Context`]
///
/// Can be used to obtain a [`Context`]. An existing weak reference does not prevent the corresponding [`Context`] from being dropped.
#[derive(Clone, Debug)]
pub(crate) struct WeakContext {
inner: Weak<InnerContext>,
}
impl WeakContext {
/// Returns the [`Context`] if it is still available.
pub(crate) fn upgrade(&self) -> Result<Context> {
let inner = self
.inner
.upgrade()
.ok_or_else(|| anyhow::anyhow!("Inner struct has been dropped"))?;
Ok(Context { inner })
}
}
/// Actual context, expensive to clone.
#[derive(Debug)]
pub struct InnerContext {
@@ -303,6 +307,17 @@ pub struct InnerContext {
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
#[expect(clippy::type_complexity)]
/// Transforms the root of the cryptographic payload before encryption.
pub(crate) pre_encrypt_mime_hook: parking_lot::Mutex<
Option<
for<'a> fn(
&Context,
mail_builder::mime::MimePart<'a>,
) -> mail_builder::mime::MimePart<'a>,
>,
>,
}
/// The state of ongoing process.
@@ -336,7 +351,7 @@ pub fn get_info() -> BTreeMap<&'static str, String> {
#[cfg(not(debug_assertions))]
res.insert("debug_assertions", "Off".to_string());
res.insert("deltachat_core_version", format!("v{}", &*DC_VERSION_STR));
res.insert("deltachat_core_version", format!("v{DC_VERSION_STR}"));
res.insert("sqlite_version", rusqlite::version().to_string());
res.insert("arch", (std::mem::size_of::<usize>() * 8).to_string());
res.insert("num_cpus", num_cpus::get().to_string());
@@ -388,10 +403,20 @@ impl Context {
Ok(context)
}
/// Returns a weak reference to this [`Context`].
pub(crate) fn get_weak_context(&self) -> WeakContext {
WeakContext {
inner: Arc::downgrade(&self.inner),
}
}
/// Opens the database with the given passphrase.
/// NB: Db encryption is deprecated, so `passphrase` should be empty normally. See
/// [`ContextBuilder::with_password()`] for reasoning.
///
/// 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?;
@@ -402,6 +427,7 @@ impl Context {
}
/// 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(())
@@ -452,7 +478,7 @@ impl Context {
translated_stockstrings: stockstrings,
events,
scheduler: SchedulerState::new(),
ratelimit: RwLock::new(Ratelimit::new(Duration::new(60, 0), 6.0)), // Allow at least 1 message every 10 seconds + a burst of 6.
ratelimit: RwLock::new(Ratelimit::new(Duration::new(3, 0), 3.0)), // Allow at least 1 message every second + a burst of 3.
quota: RwLock::new(None),
new_msgs_notify,
server_id: RwLock::new(None),
@@ -467,6 +493,7 @@ impl Context {
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};
let ctx = Context {
@@ -483,12 +510,6 @@ impl Context {
return;
}
if self.is_chatmail().await.unwrap_or_default() {
let mut lock = self.ratelimit.write().await;
// Allow at least 1 message every second + a burst of 3.
*lock = Ratelimit::new(Duration::new(3, 0), 3.0);
}
// The next line is mainly for iOS:
// iOS starts a separate process for receiving notifications and if the user concurrently
// starts the app, the UI process opens the database but waits with calling start_io()
@@ -545,7 +566,7 @@ impl Context {
.and_then(|provider| provider.opt.max_smtp_rcpt_to)
.map_or_else(
|| match is_chatmail {
true => usize::MAX,
true => constants::DEFAULT_CHATMAIL_MAX_SMTP_RCPT_TO,
false => constants::DEFAULT_MAX_SMTP_RCPT_TO,
},
usize::from,
@@ -596,10 +617,9 @@ impl Context {
if self
.quota_needs_update(DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT)
.await
&& let Err(err) = self.update_recent_quota(&mut session).await
{
if let Err(err) = self.update_recent_quota(&mut session).await {
warn!(self, "Failed to update quota: {err:#}.");
}
warn!(self, "Failed to update quota: {err:#}.");
}
}
@@ -795,11 +815,17 @@ impl Context {
/// Returns information about the context as key-value pairs.
pub async fn get_info(&self) -> Result<BTreeMap<&'static str, String>> {
let l = EnteredLoginParam::load(self).await?;
let l2 = ConfiguredLoginParam::load(self)
.await?
.map_or_else(|| "Not configured".to_string(), |param| param.to_string());
let secondary_addrs = self.get_secondary_self_addrs().await?.join(", ");
let all_transports: Vec<String> = ConfiguredLoginParam::load_all(self)
.await?
.into_iter()
.map(|(transport_id, param)| format!("{transport_id}: {param}"))
.collect();
let all_transports = if all_transports.is_empty() {
"Not configured".to_string()
} else {
all_transports.join(",")
};
let chats = get_chat_cnt(self).await?;
let unblocked_msgs = message::get_unblocked_msg_cnt(self).await;
let request_msgs = message::get_request_msg_cnt(self).await;
@@ -878,8 +904,7 @@ impl Context {
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert("proxy_enabled", proxy_enabled.to_string());
res.insert("entered_account_settings", l.to_string());
res.insert("used_account_settings", l2);
res.insert("used_transport_settings", all_transports);
if let Some(server_id) = &*self.server_id.read().await {
res.insert("imap_server_id", format!("{server_id:?}"));
@@ -920,10 +945,6 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"show_emails",
self.get_config_int(Config::ShowEmails).await?.to_string(),
);
res.insert(
"download_limit",
self.get_config_int(Config::DownloadLimit)
@@ -1051,6 +1072,13 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"test_hooks",
self.sql
.get_raw_config("test_hooks")
.await?
.unwrap_or_default(),
);
res.insert(
"fail_on_receiving_full_msg",
self.sql
@@ -1058,6 +1086,13 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"std_header_protection_composing",
self.sql
.get_raw_config("std_header_protection_composing")
.await?
.unwrap_or_default(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
@@ -1295,10 +1330,5 @@ impl Context {
}
}
/// Returns core version as a string.
pub fn get_version_str() -> &'static str {
&DC_VERSION_STR
}
#[cfg(test)]
mod context_tests;

View File

@@ -602,10 +602,7 @@ async fn test_get_next_msgs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
let alice = TestContext::new_alice().await;
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
assert_eq!(alice.get_config(Config::Displayname).await?, None);
// Change the config circumventing the cache
// This simulates what the notification plugin on iOS might do
@@ -613,24 +610,21 @@ async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
alice
.sql
.execute(
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('show_emails', '0')",
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('displayname', 'Alice 2')",
(),
)
.await?;
// Alice's Delta Chat doesn't know about it yet:
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
assert_eq!(alice.get_config(Config::Displayname).await?, None);
// Starting IO will fail of course because no server settings are configured,
// but it should invalidate the caches:
alice.start_io().await;
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("0".to_string())
alice.get_config(Config::Displayname).await?,
Some("Alice 2".to_string())
);
Ok(())

View File

@@ -115,15 +115,13 @@ pub async fn maybe_set_logging_xdc_inner(
filename: Option<&str>,
msg_id: MsgId,
) -> anyhow::Result<()> {
if viewtype == Viewtype::Webxdc {
if let Some(filename) = filename {
if filename.starts_with("debug_logging")
&& filename.ends_with(".xdc")
&& chat_id.is_self_talk(context).await?
{
set_debug_logging_xdc(context, Some(msg_id)).await?;
}
}
if viewtype == Viewtype::Webxdc
&& let Some(filename) = filename
&& filename.starts_with("debug_logging")
&& filename.ends_with(".xdc")
&& chat_id.is_self_talk(context).await?
{
set_debug_logging_xdc(context, Some(msg_id)).await?;
}
Ok(())
}

View File

@@ -13,6 +13,7 @@ use quick_xml::{
use crate::simplify::{SimplifiedText, simplify_quote};
#[derive(Default)]
struct Dehtml {
strbuilder: String,
quote: String,
@@ -25,6 +26,9 @@ struct Dehtml {
/// Everything between `<div name="quote">` and `<div name="quoted-content">` is usually metadata
/// If this is > `0`, then we are inside a `<div name="quoted-content">`.
divs_since_quoted_content_div: u32,
/// `<div class="header-protection-legacy-display">` elements should be omitted, see
/// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>.
divs_since_hp_legacy_display: u32,
/// All-Inkl just puts the quote into `<blockquote> </blockquote>`. This count is
/// increased at each `<blockquote>` and decreased at each `</blockquote>`.
blockquotes_since_blockquote: u32,
@@ -48,20 +52,25 @@ impl Dehtml {
}
fn get_add_text(&self) -> AddText {
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0 {
AddText::No // Everything between `<div name="quoted">` and `<div name="quoted_content">` is metadata which we don't want
// Everything between `<div name="quoted">` and `<div name="quoted_content">` is
// metadata which we don't want.
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0
|| self.divs_since_hp_legacy_display > 0
{
AddText::No
} else {
self.add_text
}
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
#[derive(Debug, Default, PartialEq, Clone, Copy)]
enum AddText {
/// Inside `<script>`, `<style>` and similar tags
/// which contents should not be displayed.
No,
#[default]
YesRemoveLineEnds,
/// Inside `<pre>`.
@@ -121,12 +130,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
let mut dehtml = Dehtml {
strbuilder: String::with_capacity(buf.len()),
quote: String::new(),
add_text: AddText::YesRemoveLineEnds,
last_href: None,
divs_since_quote_div: 0,
divs_since_quoted_content_div: 0,
blockquotes_since_blockquote: 0,
..Default::default()
};
let mut reader = quick_xml::Reader::from_str(buf);
@@ -244,6 +248,7 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
"div" => {
pop_tag(&mut dehtml.divs_since_quote_div);
pop_tag(&mut dehtml.divs_since_quoted_content_div);
pop_tag(&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -295,6 +300,8 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
"div" => {
maybe_push_tag(event, reader, "quote", &mut dehtml.divs_since_quote_div);
maybe_push_tag(event, reader, "quoted-content", &mut dehtml.divs_since_quoted_content_div);
maybe_push_tag(event, reader, "header-protection-legacy-display",
&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -539,6 +546,27 @@ mod tests {
assert_eq!(txt.text.trim(), "two\nlines");
}
#[test]
fn test_hp_legacy_display() {
let input = r#"
<html><head><title></title></head><body>
<div class="header-protection-legacy-display">
<pre>Subject: Dinner plans</pre>
</div>
<p>
Let's meet at Rama's Roti Shop at 8pm and go to the park
from there.
</p>
</body>
</html>
"#;
let txt = dehtml(input).unwrap();
assert_eq!(
txt.text.trim(),
"Let's meet at Rama's Roti Shop at 8pm and go to the park from there."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote_div() {
let input = include_str!("../test-data/message/gmx-quote-body.eml");

View File

@@ -153,11 +153,15 @@ pub(crate) async fn download_msg(
return Ok(());
};
let transport_id = session.transport_id();
let row = context
.sql
.query_row_optional(
"SELECT uid, folder FROM imap WHERE rfc724_mid=? AND target!=''",
(&msg.rfc724_mid,),
"SELECT uid, folder FROM imap
WHERE rfc724_mid=?
AND transport_id=?
AND target!=''",
(&msg.rfc724_mid, transport_id),
|row| {
let server_uid: u32 = row.get(0)?;
let server_folder: String = row.get(1)?;

View File

@@ -8,33 +8,27 @@ use mail_builder::mime::MimePart;
use crate::aheader::{Aheader, EncryptPreference};
use crate::context::Context;
use crate::key::{SignedPublicKey, load_self_public_key, load_self_secret_key};
use crate::pgp;
use crate::pgp::{self, SeipdVersion};
#[derive(Debug)]
pub struct EncryptHelper {
pub prefer_encrypt: EncryptPreference,
pub addr: String,
pub public_key: SignedPublicKey,
}
impl EncryptHelper {
pub async fn new(context: &Context) -> Result<EncryptHelper> {
let prefer_encrypt = EncryptPreference::Mutual;
let addr = context.get_primary_self_addr().await?;
let public_key = load_self_public_key(context).await?;
Ok(EncryptHelper {
prefer_encrypt,
addr,
public_key,
})
Ok(EncryptHelper { addr, public_key })
}
pub fn get_aheader(&self) -> Aheader {
Aheader {
addr: self.addr.clone(),
public_key: self.public_key.clone(),
prefer_encrypt: self.prefer_encrypt,
prefer_encrypt: EncryptPreference::Mutual,
verified: false,
}
}
@@ -47,6 +41,7 @@ impl EncryptHelper {
mail_to_encrypt: MimePart<'static>,
compress: bool,
anonymous_recipients: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
@@ -57,9 +52,10 @@ impl EncryptHelper {
let ctext = pgp::pk_encrypt(
raw_message,
keyring,
Some(sign_key),
sign_key,
compress,
anonymous_recipients,
seipd_version,
)
.await?;

View File

@@ -241,10 +241,9 @@ pub(crate) async fn stock_ephemeral_timer_changed(
match timer {
Timer::Disabled => stock_str::msg_ephemeral_timer_disabled(context, from_id).await,
Timer::Enabled { duration } => match duration {
0..=59 => {
0..=60 => {
stock_str::msg_ephemeral_timer_enabled(context, &timer.to_string(), from_id).await
}
60 => stock_str::msg_ephemeral_timer_minute(context, from_id).await,
61..=3599 => {
stock_str::msg_ephemeral_timer_minutes(
context,

View File

@@ -38,7 +38,7 @@ async fn test_stock_ephemeral_messages() {
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 60 }, ContactId::SELF)
.await,
"You set message deletion timer to 1 minute."
"You set message deletion timer to 60 s."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 90 }, ContactId::SELF)
@@ -142,7 +142,7 @@ async fn test_ephemeral_enable_disable() -> Result<()> {
let bob_received_message = bob.recv_msg(&sent).await;
assert_eq!(
bob_received_message.text,
"Message deletion timer is set to 1 minute by alice@example.org."
"Message deletion timer is set to 60 s by alice@example.org."
);
assert_eq!(
chat_bob.get_ephemeral_timer(bob).await?,
@@ -451,6 +451,8 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
let t = TestContext::new_alice().await;
const HOUR: i64 = 60 * 60;
let now = time();
let transport_id = 1;
let uidvalidity = 12345;
for (id, timestamp, ephemeral_timestamp) in &[
(900, now - 2 * HOUR, 0),
(1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0),
@@ -470,8 +472,8 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
.await?;
t.sql
.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, target) VALUES (?,'INBOX',?, 'INBOX');",
(&message_id, id),
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, target, uidvalidity) VALUES (?, ?,'INBOX',?, 'INBOX', ?);",
(transport_id, &message_id, id, uidvalidity),
)
.await?;
}

View File

@@ -30,9 +30,10 @@ pub(crate) async fn emit_chatlist_item_changed_for_contact_chat(
match ChatId::lookup_by_contact(context, contact_id).await {
Ok(Some(chat_id)) => self::emit_chatlist_item_changed(context, chat_id),
Ok(None) => {}
Err(error) => context.emit_event(EventType::Error(format!(
Err(error) => error!(
context,
"failed to find chat id for contact for chatlist event: {error:?}"
))),
),
}
}

View File

@@ -243,7 +243,7 @@ pub enum EventType {
/// Progress.
///
/// 0=error, 1-999=progress in permille, 1000=success and done
progress: usize,
progress: u16,
/// Progress comment or error, something to display to the user.
comment: Option<String>,
@@ -253,7 +253,7 @@ pub enum EventType {
///
/// @param data1 (usize) 0=error, 1-999=progress in permille, 1000=success and done
/// @param data2 0
ImexProgress(usize),
ImexProgress(u16),
/// A file has been exported. A file has been written by imex().
/// This event may be sent multiple times by a single call to imex().
@@ -280,7 +280,7 @@ pub enum EventType {
chat_type: Chattype,
/// Progress, always 1000.
progress: usize,
progress: u16,
},
/// Progress information of a secure-join handshake from the view of the joiner
@@ -295,7 +295,7 @@ pub enum EventType {
/// 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)
/// 1000=vg-member-added/vc-contact-confirm received
progress: usize,
progress: u16,
},
/// The connectivity to the server changed.
@@ -417,6 +417,15 @@ pub enum EventType {
chat_id: ChatId,
},
/// One or more transports has changed.
///
/// This event is used for tests to detect when transport
/// synchronization messages arrives.
/// UIs don't need to use it, it is unlikely
/// that user modifies transports on multiple
/// devices simultaneously.
TransportsModified,
/// Event for using in tests, e.g. as a fence between normally generated events.
#[cfg(test)]
Test,

View File

@@ -138,6 +138,9 @@ pub enum HeaderDef {
/// Advertised gossip topic for one webxdc.
IrohGossipTopic,
/// See <https://www.rfc-editor.org/rfc/rfc9788.html#name-hp-outer-header-field>.
HpOuter,
#[cfg(test)]
TestHeader,
}

View File

@@ -169,27 +169,28 @@ impl HtmlMsgParser {
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype == mime::TEXT_HTML {
if self.html.is_empty() {
if let Ok(decoded_data) = mail.get_body() {
self.html = decoded_data;
}
}
} else if mimetype == mime::TEXT_PLAIN && self.plain.is_none() {
if let Ok(decoded_data) = mail.get_body() {
self.plain = Some(PlainText {
text: decoded_data,
flowed: if let Some(format) = mail.ctype.params.get("format") {
format.as_str().eq_ignore_ascii_case("flowed")
} else {
false
},
delsp: if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
},
});
if self.html.is_empty()
&& let Ok(decoded_data) = mail.get_body()
{
self.html = decoded_data;
}
} else if mimetype == mime::TEXT_PLAIN
&& self.plain.is_none()
&& let Ok(decoded_data) = mail.get_body()
{
self.plain = Some(PlainText {
text: decoded_data,
flowed: if let Some(format) = mail.ctype.params.get("format") {
format.as_str().eq_ignore_ascii_case("flowed")
} else {
false
},
delsp: if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
},
});
}
Ok(())
}
@@ -213,31 +214,29 @@ impl HtmlMsgParser {
MimeMultipartType::Message => Ok(()),
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype.type_() == mime::IMAGE {
if let Some(cid) = mail.headers.get_header_value(HeaderDef::ContentId) {
if let Ok(cid) = parse_message_id(&cid) {
if let Ok(replacement) = mimepart_to_data_url(mail) {
let re_string = format!(
"(<img[^>]*src[^>]*=[^>]*)(cid:{})([^>]*>)",
regex::escape(&cid)
);
match regex::Regex::new(&re_string) {
Ok(re) => {
self.html = re
.replace_all(
&self.html,
format!("${{1}}{replacement}${{3}}").as_str(),
)
.as_ref()
.to_string()
}
Err(e) => warn!(
context,
"Cannot create regex for cid: {} throws {}", re_string, e
),
}
}
if mimetype.type_() == mime::IMAGE
&& let Some(cid) = mail.headers.get_header_value(HeaderDef::ContentId)
&& let Ok(cid) = parse_message_id(&cid)
&& let Ok(replacement) = mimepart_to_data_url(mail)
{
let re_string = format!(
"(<img[^>]*src[^>]*=[^>]*)(cid:{})([^>]*>)",
regex::escape(&cid)
);
match regex::Regex::new(&re_string) {
Ok(re) => {
self.html = re
.replace_all(
&self.html,
format!("${{1}}{replacement}${{3}}").as_str(),
)
.as_ref()
.to_string()
}
Err(e) => warn!(
context,
"Cannot create regex for cid: {} throws {}", re_string, e
),
}
}
Ok(())
@@ -282,7 +281,6 @@ mod tests {
use super::*;
use crate::chat;
use crate::chat::{forward_msgs, save_msgs};
use crate::config::Config;
use crate::contact::ContactId;
use crate::message::{MessengerMessage, Viewtype};
use crate::receive_imf::receive_imf;
@@ -523,13 +521,7 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
async fn test_html_forwarding_encrypted() {
let mut tcm = TestContextManager::new();
// Alice receives a non-delta html-message
// (`ShowEmails=AcceptedContacts` lets Alice actually receive non-delta messages for known
// contacts, the contact is marked as known by creating a chat using `chat_with_contact()`)
let alice = &tcm.alice().await;
alice
.set_config(Config::ShowEmails, Some("1"))
.await
.unwrap();
let chat = alice
.create_chat_with_contact("", "sender@testrun.org")
.await;
@@ -547,10 +539,6 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
// receive the message on another device
let alice = &tcm.alice().await;
alice
.set_config(Config::ShowEmails, Some("0"))
.await
.unwrap();
let msg = alice.recv_msg(&msg).await;
assert_eq!(msg.chat_id, alice.get_self_chat().await.id);
assert_eq!(msg.get_from_id(), ContactId::SELF);

View File

@@ -19,7 +19,6 @@ use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use deltachat_contact_tools::ContactAddress;
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use ratelimit::Ratelimit;
use url::Url;
@@ -27,7 +26,7 @@ use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{self, Blocked, ShowEmails};
use crate::constants::{self, Blocked, Chattype, DC_VERSION_STR};
use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::context::Context;
use crate::events::EventType;
@@ -71,10 +70,15 @@ const BODY_PARTIAL: &str = "(FLAGS RFC822.SIZE BODY.PEEK[HEADER])";
#[derive(Debug)]
pub(crate) struct Imap {
/// ID of the transport configuration in the `transports` table.
///
/// This ID is used to namespace records in the `imap` table.
transport_id: u32,
pub(crate) idle_interrupt_receiver: Receiver<()>,
/// Email address.
addr: String,
pub(crate) addr: String,
/// Login parameters.
lp: Vec<ConfiguredServerLoginParam>,
@@ -118,7 +122,7 @@ struct OAuth2 {
access_token: String,
}
#[derive(Debug)]
#[derive(Debug, Default)]
pub(crate) struct ServerMetadata {
/// IMAP METADATA `/shared/comment` as defined in
/// <https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1>.
@@ -249,19 +253,21 @@ impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
impl Imap {
/// Creates new disconnected IMAP client using the specific login parameters.
///
/// `addr` is used to renew token if OAuth2 authentication is used.
pub fn new(
lp: Vec<ConfiguredServerLoginParam>,
password: String,
proxy_config: Option<ProxyConfig>,
addr: &str,
strict_tls: bool,
oauth2: bool,
pub async fn new(
context: &Context,
transport_id: u32,
param: ConfiguredLoginParam,
idle_interrupt_receiver: Receiver<()>,
) -> Self {
) -> Result<Self> {
let lp = param.imap.clone();
let password = param.imap_password.clone();
let proxy_config = ProxyConfig::load(context).await?;
let addr = &param.addr;
let strict_tls = param.strict_tls(proxy_config.is_some());
let oauth2 = param.oauth2;
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
Imap {
Ok(Imap {
transport_id,
idle_interrupt_receiver,
addr: addr.to_string(),
lp,
@@ -277,7 +283,7 @@ impl Imap {
ratelimit: Ratelimit::new(Duration::new(120, 0), 2.0),
resync_request_sender,
resync_request_receiver,
}
})
}
/// Creates new disconnected IMAP client using configured parameters.
@@ -285,20 +291,10 @@ impl Imap {
context: &Context,
idle_interrupt_receiver: Receiver<()>,
) -> Result<Self> {
let param = ConfiguredLoginParam::load(context)
let (transport_id, param) = ConfiguredLoginParam::load(context)
.await?
.context("Not configured")?;
let proxy_config = ProxyConfig::load(context).await?;
let strict_tls = param.strict_tls(proxy_config.is_some());
let imap = Self::new(
param.imap.clone(),
param.imap_password.clone(),
proxy_config,
&param.addr,
strict_tls,
param.oauth2,
idle_interrupt_receiver,
);
let imap = Self::new(context, transport_id, param, idle_interrupt_receiver).await?;
Ok(imap)
}
@@ -412,9 +408,19 @@ impl Imap {
})
.await
.context("Failed to enable IMAP compression")?;
Session::new(compressed_session, capabilities, resync_request_sender)
Session::new(
compressed_session,
capabilities,
resync_request_sender,
self.transport_id,
)
} else {
Session::new(session, capabilities, resync_request_sender)
Session::new(
session,
capabilities,
resync_request_sender,
self.transport_id,
)
};
// Store server ID in the context to display in account info.
@@ -593,8 +599,9 @@ impl Imap {
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<(usize, bool)> {
let uid_validity = get_uidvalidity(context, folder).await?;
let old_uid_next = get_uid_next(context, folder).await?;
let transport_id = self.transport_id;
let uid_validity = get_uidvalidity(context, transport_id, folder).await?;
let old_uid_next = get_uid_next(context, transport_id, folder).await?;
info!(
context,
"fetch_new_msg_batch({folder}): UIDVALIDITY={uid_validity}, UIDNEXT={old_uid_next}."
@@ -662,12 +669,19 @@ impl Imap {
context
.sql
.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(folder, uid, uidvalidity)
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(transport_id, folder, uid, uidvalidity)
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
target=excluded.target",
(&message_id, &folder, uid, uid_validity, target),
(
self.transport_id,
&message_id,
&folder,
uid,
uid_validity,
target,
),
)
.await?;
@@ -778,7 +792,7 @@ impl Imap {
prefetch_uid_next < mailbox_uid_next
};
if new_uid_next > old_uid_next {
set_uid_next(context, folder, new_uid_next).await?;
set_uid_next(context, self.transport_id, folder, new_uid_next).await?;
}
info!(context, "{} mails read from \"{}\".", read_cnt, folder);
@@ -858,6 +872,7 @@ impl Session {
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let transport_id = self.transport_id();
if folder_exists {
let mut list = self
.uid_fetch("1:*", RFC724MID_UID)
@@ -890,7 +905,7 @@ impl Session {
msgs.len(),
);
uid_validity = get_uidvalidity(context, folder).await?;
uid_validity = get_uidvalidity(context, transport_id, folder).await?;
} else {
warn!(context, "resync_folder_uids: No folder {folder}.");
uid_validity = 0;
@@ -900,17 +915,17 @@ impl Session {
context
.sql
.transaction(move |transaction| {
transaction.execute("DELETE FROM imap WHERE folder=?", (folder,))?;
transaction.execute("DELETE FROM imap WHERE transport_id=? AND folder=?", (transport_id, folder,))?;
for (uid, (rfc724_mid, target)) in &msgs {
// This may detect previously undetected moved
// messages, so we update server_folder too.
transaction.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(folder, uid, uidvalidity)
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(transport_id, folder, uid, uidvalidity)
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
target=excluded.target",
(rfc724_mid, folder, uid, uid_validity, target),
(transport_id, rfc724_mid, folder, uid, uid_validity, target),
)?;
}
Ok(())
@@ -1038,14 +1053,16 @@ impl Session {
///
/// 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<()> {
let transport_id = self.transport_id();
let rows = context
.sql
.query_map_vec(
"SELECT id, uid, target FROM imap
WHERE folder = ?
AND target != folder
ORDER BY target, uid",
(folder,),
WHERE folder = ?
AND transport_id = ?
AND target != folder
ORDER BY target, uid",
(folder, transport_id),
|row| {
let rowid: i64 = row.get(0)?;
let uid: u32 = row.get(1)?;
@@ -1232,11 +1249,12 @@ impl Session {
return Ok(());
}
let transport_id = self.transport_id();
let mut updated_chat_ids = BTreeSet::new();
let uid_validity = get_uidvalidity(context, folder)
let uid_validity = get_uidvalidity(context, transport_id, folder)
.await
.with_context(|| format!("failed to get UID validity for folder {folder}"))?;
let mut highest_modseq = get_modseq(context, folder)
let mut highest_modseq = get_modseq(context, transport_id, folder)
.await
.with_context(|| format!("failed to get MODSEQ for folder {folder}"))?;
let mut list = self
@@ -1259,15 +1277,14 @@ impl Session {
continue;
};
let is_seen = fetch.flags().any(|flag| flag == Flag::Seen);
if is_seen {
if let Some(chat_id) = mark_seen_by_uid(context, folder, uid_validity, uid)
if is_seen
&& let Some(chat_id) = mark_seen_by_uid(context, transport_id, folder, uid_validity, uid)
.await
.with_context(|| {
format!("failed to update seen status for msg {folder}/{uid}")
format!("Transport {transport_id}: Failed to update seen status for msg {folder}/{uid}")
})?
{
updated_chat_ids.insert(chat_id);
}
{
updated_chat_ids.insert(chat_id);
}
if let Some(modseq) = fetch.modseq {
@@ -1288,7 +1305,7 @@ impl Session {
self.new_mail = true;
}
set_modseq(context, folder, highest_modseq)
set_modseq(context, transport_id, folder, highest_modseq)
.await
.with_context(|| format!("failed to set MODSEQ for folder {folder}"))?;
if !updated_chat_ids.is_empty() {
@@ -1321,10 +1338,10 @@ impl Session {
while let Some(msg) = list.try_next().await? {
match get_fetch_headers(&msg) {
Ok(headers) => {
if let Some(from) = mimeparser::get_from(&headers) {
if context.is_self_addr(&from.addr).await? {
result.extend(mimeparser::get_recipients(&headers));
}
if let Some(from) = mimeparser::get_from(&headers)
&& context.is_self_addr(&from.addr).await?
{
result.extend(mimeparser::get_recipients(&headers));
}
}
Err(err) => {
@@ -1484,7 +1501,7 @@ impl Session {
warn!(context, "receive_imf error: {err:#}.");
let text = format!(
"❌ Failed to receive a message: {err:#}. Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
"❌ Failed to receive a message: {err:#}. Core version v{DC_VERSION_STR}. Please report this bug to delta@merlinux.eu or https://support.delta.chat/.",
);
let mut msg = Message::new_text(text);
add_device_msg(context, None, Some(&mut msg)).await?;
@@ -1530,17 +1547,17 @@ impl Session {
Ok(())
}
/// Retrieves server metadata if it is supported.
/// Retrieves server metadata if it is supported, otherwise uses fallback one.
///
/// We get [`/shared/comment`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1)
/// and [`/shared/admin`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2)
/// metadata.
pub(crate) async fn fetch_metadata(&mut self, context: &Context) -> Result<()> {
if !self.can_metadata() {
return Ok(());
}
pub(crate) async fn update_metadata(&mut self, context: &Context) -> Result<()> {
let mut lock = context.metadata.write().await;
if !self.can_metadata() {
*lock = Some(Default::default());
}
if let Some(ref mut old_metadata) = *lock {
let now = time();
@@ -1549,21 +1566,23 @@ impl Session {
return Ok(());
}
info!(context, "ICE servers expired, requesting new credentials.");
let mailbox = "";
let options = "";
let metadata = self
.get_metadata(mailbox, options, "(/shared/vendor/deltachat/turn)")
.await?;
let mut got_turn_server = false;
for m in metadata {
if m.entry == "/shared/vendor/deltachat/turn" {
if let Some(value) = m.value {
if self.can_metadata() {
info!(context, "ICE servers expired, requesting new credentials.");
let mailbox = "";
let options = "";
let metadata = self
.get_metadata(mailbox, options, "(/shared/vendor/deltachat/turn)")
.await?;
for m in metadata {
if m.entry == "/shared/vendor/deltachat/turn"
&& let Some(value) = m.value
{
match create_ice_servers_from_metadata(context, &value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
old_metadata.ice_servers = parsed_ice_servers;
got_turn_server = false;
got_turn_server = true;
}
Err(err) => {
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
@@ -1572,8 +1591,8 @@ impl Session {
}
}
}
if !got_turn_server {
info!(context, "Will use fallback ICE servers.");
// Set expiration timestamp 7 days in the future so we don't request it again.
old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
old_metadata.ice_servers = create_fallback_ice_servers(context).await?;
@@ -1852,11 +1871,13 @@ impl Imap {
info!(context, "Scanning folder: {:?}", folder);
// Update the delimiter iff there is a different one, but only once.
if let Some(d) = folder.delimiter() {
if delimiter_is_default && !d.is_empty() && delimiter != d {
delimiter = d.to_string();
delimiter_is_default = false;
}
if let Some(d) = folder.delimiter()
&& delimiter_is_default
&& !d.is_empty()
&& delimiter != d
{
delimiter = d.to_string();
delimiter_is_default = false;
}
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
@@ -1964,24 +1985,21 @@ impl Session {
}
}
fn is_encrypted(headers: &[mailparse::MailHeader<'_>]) -> bool {
let content_type = headers.get_header_value(HeaderDef::ContentType);
let content_type = content_type.as_ref();
let res = content_type.is_some_and(|v| v.contains("multipart/encrypted"));
// Some MUAs use "multipart/mixed", look also at Subject in this case. We can't only look at
// Subject as it's not mandatory (<https://datatracker.ietf.org/doc/html/rfc5322#section-3.6>)
// and may be user-formed.
res || content_type.is_some_and(|v| v.contains("multipart/mixed"))
&& headers
.get_header_value(HeaderDef::Subject)
.is_some_and(|v| v == "..." || v == "[...]")
}
async fn should_move_out_of_spam(
context: &Context,
headers: &[mailparse::MailHeader<'_>],
) -> Result<bool> {
if headers.get_header_value(HeaderDef::SecureJoin).is_some() || is_encrypted(headers) {
if headers.get_header_value(HeaderDef::ChatVersion).is_some() {
// If this is a chat message (i.e. has a ChatVersion header), then this might be
// a securejoin message. We can't find out at this point as we didn't prefetch
// the SecureJoin header. So, we always move chat messages out of Spam.
// Two possibilities to change this would be:
// 1. Remove the `&& !context.is_spam_folder(folder).await?` check from
// `fetch_new_messages()`, and then let `receive_imf()` check
// if it's a spam message and should be hidden.
// 2. Or add a flag to the ChatVersion header that this is a securejoin
// request, and return `true` here only if the message has this flag.
// `receive_imf()` can then check if the securejoin request is valid.
return Ok(true);
}
@@ -2040,8 +2058,7 @@ async fn spam_target_folder_cfg(
return Ok(None);
}
if is_encrypted(headers) && context.get_config_bool(Config::MvboxMove).await?
|| needs_move_to_mvbox(context, headers).await?
if needs_move_to_mvbox(context, headers).await?
// If OnlyFetchMvbox is set, we don't want to move the message to
// the inbox where we wouldn't fetch it again:
|| context.get_config_bool(Config::OnlyFetchMvbox).await?
@@ -2094,6 +2111,18 @@ async fn needs_move_to_mvbox(
context: &Context,
headers: &[mailparse::MailHeader<'_>],
) -> Result<bool> {
let has_chat_version = headers.get_header_value(HeaderDef::ChatVersion).is_some();
if !context.get_config_bool(Config::IsChatmail).await?
&& has_chat_version
&& headers
.get_header_value(HeaderDef::AutoSubmitted)
.filter(|val| val.eq_ignore_ascii_case("auto-generated"))
.is_some()
&& let Some(from) = mimeparser::get_from(headers)
&& context.is_self_addr(&from.addr).await?
{
return Ok(true);
}
if !context.get_config_bool(Config::MvboxMove).await? {
return Ok(false);
}
@@ -2106,7 +2135,17 @@ async fn needs_move_to_mvbox(
// there may be a non-delta device that wants to handle it
return Ok(false);
}
Ok(headers.get_header_value(HeaderDef::SecureJoin).is_some() || is_encrypted(headers))
if has_chat_version {
Ok(true)
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {
match parent.is_dc_message {
MessengerMessage::No => Ok(false),
MessengerMessage::Yes | MessengerMessage::Reply => Ok(true),
}
} else {
Ok(false)
}
}
/// Try to get the folder meaning by the name of the folder only used if the server does not support XLIST.
@@ -2219,6 +2258,21 @@ pub(crate) fn create_message_id() -> String {
format!("{}{}", GENERATED_PREFIX, create_id())
}
/// Returns chat by prefetched headers.
async fn prefetch_get_chat(
context: &Context,
headers: &[mailparse::MailHeader<'_>],
) -> Result<Option<chat::Chat>> {
let parent = get_prefetch_parent_message(context, headers).await?;
if let Some(parent) = &parent {
return Ok(Some(
chat::Chat::load_from_db(context, parent.get_chat_id()).await?,
));
}
Ok(None)
}
/// Determines whether the message should be downloaded based on prefetched headers.
pub(crate) async fn prefetch_should_download(
context: &Context,
@@ -2237,6 +2291,15 @@ pub(crate) async fn prefetch_should_download(
// We do not know the Message-ID or the Message-ID is missing (in this case, we create one in
// the further process).
if let Some(chat) = prefetch_get_chat(context, headers).await?
&& chat.typ == Chattype::Group
&& !chat.id.is_special()
{
// This might be a group command, like removing a group member.
// We really need to fetch this to avoid inconsistent group state.
return Ok(true);
}
let maybe_ndn = if let Some(from) = headers.get_header_value(HeaderDef::From_) {
let from = from.to_ascii_lowercase();
from.contains("mailer-daemon") || from.contains("mail-daemon")
@@ -2253,7 +2316,7 @@ pub(crate) async fn prefetch_should_download(
Some(f) => f,
None => return Ok(false),
};
let (_from_id, blocked_contact, origin) =
let (_from_id, blocked_contact, _origin) =
match from_field_to_contact_id(context, &from, None, true, true).await? {
Some(res) => res,
None => return Ok(false),
@@ -2266,26 +2329,7 @@ pub(crate) async fn prefetch_should_download(
return Ok(false);
}
let accepted_contact = origin.is_known();
let show = is_autocrypt_setup_message
|| headers.get_header_value(HeaderDef::SecureJoin).is_some()
|| is_encrypted(headers)
|| match ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?)
.unwrap_or_default()
{
ShowEmails::Off => false,
ShowEmails::AcceptedContacts => accepted_contact,
ShowEmails::All => true,
}
|| get_prefetch_parent_message(context, headers)
.await?
.map(|parent| match parent.is_dc_message {
MessengerMessage::No => false,
MessengerMessage::Yes | MessengerMessage::Reply => true,
})
.unwrap_or_default();
let should_download = (show && !blocked_contact) || maybe_ndn;
let should_download = !blocked_contact || maybe_ndn;
Ok(should_download)
}
@@ -2294,6 +2338,7 @@ pub(crate) async fn prefetch_should_download(
/// Returns updated chat ID if any message was marked as seen.
async fn mark_seen_by_uid(
context: &Context,
transport_id: u32,
folder: &str,
uid_validity: u32,
uid: u32,
@@ -2304,12 +2349,13 @@ async fn mark_seen_by_uid(
"SELECT id, chat_id FROM msgs
WHERE id > 9 AND rfc724_mid IN (
SELECT rfc724_mid FROM imap
WHERE folder=?1
AND uidvalidity=?2
AND uid=?3
WHERE transport_id=?
AND folder=?
AND uidvalidity=?
AND uid=?
LIMIT 1
)",
(&folder, uid_validity, uid),
(transport_id, &folder, uid_validity, uid),
|row| {
let msg_id: MsgId = row.get(0)?;
let chat_id: ChatId = row.get(1)?;
@@ -2371,13 +2417,18 @@ pub(crate) async fn markseen_on_imap_table(context: &Context, message_id: &str)
/// uid_next is the next unique identifier value from the last time we fetched a folder
/// See <https://tools.ietf.org/html/rfc3501#section-2.3.1.1>
/// This function is used to update our uid_next after fetching messages.
pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32) -> Result<()> {
pub(crate) async fn set_uid_next(
context: &Context,
transport_id: u32,
folder: &str,
uid_next: u32,
) -> Result<()> {
context
.sql
.execute(
"INSERT INTO imap_sync (folder, uid_next) VALUES (?,?)
ON CONFLICT(folder) DO UPDATE SET uid_next=excluded.uid_next",
(folder, uid_next),
"INSERT INTO imap_sync (transport_id, folder, uid_next) VALUES (?, ?,?)
ON CONFLICT(transport_id, folder) DO UPDATE SET uid_next=excluded.uid_next",
(transport_id, folder, uid_next),
)
.await?;
Ok(())
@@ -2388,57 +2439,69 @@ pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32)
/// This method returns the uid_next from the last time we fetched messages.
/// We can compare this to the current uid_next to find out whether there are new messages
/// and fetch from this value on to get all new messages.
async fn get_uid_next(context: &Context, folder: &str) -> Result<u32> {
async fn get_uid_next(context: &Context, transport_id: u32, folder: &str) -> Result<u32> {
Ok(context
.sql
.query_get_value("SELECT uid_next FROM imap_sync WHERE folder=?;", (folder,))
.query_get_value(
"SELECT uid_next FROM imap_sync WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?
.unwrap_or(0))
}
pub(crate) async fn set_uidvalidity(
context: &Context,
transport_id: u32,
folder: &str,
uidvalidity: u32,
) -> Result<()> {
context
.sql
.execute(
"INSERT INTO imap_sync (folder, uidvalidity) VALUES (?,?)
ON CONFLICT(folder) DO UPDATE SET uidvalidity=excluded.uidvalidity",
(folder, uidvalidity),
"INSERT INTO imap_sync (transport_id, folder, uidvalidity) VALUES (?,?,?)
ON CONFLICT(transport_id, folder) DO UPDATE SET uidvalidity=excluded.uidvalidity",
(transport_id, folder, uidvalidity),
)
.await?;
Ok(())
}
async fn get_uidvalidity(context: &Context, folder: &str) -> Result<u32> {
async fn get_uidvalidity(context: &Context, transport_id: u32, folder: &str) -> Result<u32> {
Ok(context
.sql
.query_get_value(
"SELECT uidvalidity FROM imap_sync WHERE folder=?;",
(folder,),
"SELECT uidvalidity FROM imap_sync WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?
.unwrap_or(0))
}
pub(crate) async fn set_modseq(context: &Context, folder: &str, modseq: u64) -> Result<()> {
pub(crate) async fn set_modseq(
context: &Context,
transport_id: u32,
folder: &str,
modseq: u64,
) -> Result<()> {
context
.sql
.execute(
"INSERT INTO imap_sync (folder, modseq) VALUES (?,?)
ON CONFLICT(folder) DO UPDATE SET modseq=excluded.modseq",
(folder, modseq),
"INSERT INTO imap_sync (transport_id, folder, modseq) VALUES (?,?,?)
ON CONFLICT(transport_id, folder) DO UPDATE SET modseq=excluded.modseq",
(transport_id, folder, modseq),
)
.await?;
Ok(())
}
async fn get_modseq(context: &Context, folder: &str) -> Result<u64> {
async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Result<u64> {
Ok(context
.sql
.query_get_value("SELECT modseq FROM imap_sync WHERE folder=?;", (folder,))
.query_get_value(
"SELECT modseq FROM imap_sync WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?
.unwrap_or(0))
}
@@ -2478,11 +2541,11 @@ fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
let mut ranges: Vec<UidRange> = vec![];
for &current in uids {
if let Some(last) = ranges.last_mut() {
if last.end + 1 == current {
last.end = current;
continue;
}
if let Some(last) = ranges.last_mut()
&& last.end + 1 == current
{
last.end = current;
continue;
}
ranges.push(UidRange {

View File

@@ -207,6 +207,7 @@ impl Client {
hostname: &str,
strict_tls: bool,
) -> Result<Self> {
let use_sni = true;
let tcp_stream = connect_tcp_inner(addr).await?;
let account_id = context.get_id();
let events = context.events.clone();
@@ -215,6 +216,7 @@ impl Client {
strict_tls,
hostname,
addr.port(),
use_sni,
alpn(addr.port()),
logging_stream,
&context.tls_session_store,
@@ -251,6 +253,7 @@ impl Client {
host: &str,
strict_tls: bool,
) -> Result<Self> {
let use_sni = false;
let tcp_stream = connect_tcp_inner(addr).await?;
let account_id = context.get_id();
@@ -275,6 +278,7 @@ impl Client {
strict_tls,
host,
addr.port(),
use_sni,
"",
tcp_stream,
&context.tls_session_store,
@@ -294,6 +298,7 @@ impl Client {
strict_tls: bool,
proxy_config: ProxyConfig,
) -> Result<Self> {
let use_sni = true;
let proxy_stream = proxy_config
.connect(context, domain, port, strict_tls)
.await?;
@@ -301,6 +306,7 @@ impl Client {
strict_tls,
domain,
port,
use_sni,
alpn(port),
proxy_stream,
&context.tls_session_store,
@@ -340,6 +346,7 @@ impl Client {
proxy_config: ProxyConfig,
strict_tls: bool,
) -> Result<Self> {
let use_sni = false;
let proxy_stream = proxy_config
.connect(context, hostname, port, strict_tls)
.await?;
@@ -362,6 +369,7 @@ impl Client {
strict_tls,
hostname,
port,
use_sni,
"",
proxy_stream,
&context.tls_session_store,

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::test_utils::TestContext;
use crate::transport::add_pseudo_transport;
#[test]
fn test_get_folder_meaning_by_name() {
@@ -11,17 +12,23 @@ fn test_get_folder_meaning_by_name() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_uid_next_validity() {
let t = TestContext::new_alice().await;
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0);
assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 0);
set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap();
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7);
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
set_uidvalidity(&t.ctx, 1, "Inbox", 7).await.unwrap();
assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 7);
assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 0);
set_uid_next(&t.ctx, "Inbox", 5).await.unwrap();
set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap();
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6);
// For another transport there is still no UIDVALIDITY set.
assert_eq!(get_uidvalidity(&t.ctx, 2, "Inbox").await.unwrap(), 0);
set_uid_next(&t.ctx, 1, "Inbox", 5).await.unwrap();
set_uidvalidity(&t.ctx, 1, "Inbox", 6).await.unwrap();
assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 5);
assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 6);
assert_eq!(get_uid_next(&t.ctx, 2, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, 2, "Inbox").await.unwrap(), 0);
}
#[test]
@@ -94,14 +101,14 @@ fn test_build_sequence_sets() {
async fn check_target_folder_combination(
folder: &str,
mvbox_move: bool,
is_encrypted: bool,
chat_msg: bool,
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
setupmessage: bool,
) -> Result<()> {
println!(
"Testing: For folder {folder}, mvbox_move {mvbox_move}, is_encrypted {is_encrypted}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"
);
let t = TestContext::new_alice().await;
@@ -124,6 +131,7 @@ async fn check_target_folder_combination(
temp = format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
{}\
Subject: foo\n\
Message-ID: <abc@example.com>\n\
{}\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
@@ -134,12 +142,7 @@ async fn check_target_folder_combination(
} else {
"From: bob@example.net\nTo: alice@example.org\n"
},
if is_encrypted {
"Subject: [...]\n\
Content-Type: multipart/mixed; boundary=\"someboundary\"\n"
} else {
"Subject: foo\n"
},
if chat_msg { "Chat-Version: 1.0\n" } else { "" },
);
temp.as_bytes()
};
@@ -161,26 +164,25 @@ async fn check_target_folder_combination(
assert_eq!(
expected,
actual.as_deref(),
"For folder {folder}, mvbox_move {mvbox_move}, is_encrypted {is_encrypted}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
// The tuples are (folder, mvbox_move, is_encrypted, expected_destination)
// chat_msg means that the message was sent by Delta Chat
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Spam", false, false, "INBOX"),
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", false, true, "INBOX"),
// Move unencrypted emails in accepted chats from Spam to INBOX, not 100% sure on this, we could
// also not move unencrypted emails or, if mvbox_move=1, move them to DeltaChat.
("Spam", true, false, "INBOX"),
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, true, "DeltaChat"),
];
// These are the same as above, but unencrypted messages in Spam stay in Spam.
// These are the same as above, but non-chat messages in Spam stay in Spam
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
@@ -194,11 +196,11 @@ const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_accepted() -> Result<()> {
for (folder, mvbox_move, is_encrypted, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*is_encrypted,
*chat_msg,
expected_destination,
true,
false,
@@ -211,11 +213,11 @@ async fn test_target_folder_incoming_accepted() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_request() -> Result<()> {
for (folder, mvbox_move, is_encrypted, expected_destination) in COMBINATIONS_REQUEST {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(
folder,
*mvbox_move,
*is_encrypted,
*chat_msg,
expected_destination,
false,
false,
@@ -229,11 +231,11 @@ async fn test_target_folder_incoming_request() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_outgoing() -> Result<()> {
// Test outgoing emails
for (folder, mvbox_move, is_encrypted, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*is_encrypted,
*chat_msg,
expected_destination,
true,
true,
@@ -247,11 +249,11 @@ async fn test_target_folder_outgoing() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_setupmsg() -> Result<()> {
// Test setupmessages
for (folder, mvbox_move, is_encrypted, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*is_encrypted,
*chat_msg,
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
false,
true,
@@ -270,12 +272,14 @@ async fn test_get_imap_search_command() -> Result<()> {
r#"FROM "alice@example.org""#
);
add_pseudo_transport(&t, "alice@another.com").await?;
t.ctx.set_primary_self_addr("alice@another.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"#
);
add_pseudo_transport(&t, "alice@third.com").await?;
t.ctx.set_primary_self_addr("alice@third.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,

View File

@@ -5,6 +5,7 @@ use anyhow::Context as _;
use super::session::Session as ImapSession;
use super::{get_uid_next, get_uidvalidity, set_modseq, set_uid_next, set_uidvalidity};
use crate::context::Context;
use crate::ensure_and_debug_assert;
use crate::log::warn;
type Result<T> = std::result::Result<T, Error>;
@@ -34,16 +35,16 @@ impl ImapSession {
/// because no EXPUNGE responses are sent, see
/// <https://tools.ietf.org/html/rfc3501#section-6.4.2>
pub(super) async fn maybe_close_folder(&mut self, context: &Context) -> anyhow::Result<()> {
if let Some(folder) = &self.selected_folder {
if self.selected_folder_needs_expunge {
info!(context, "Expunge messages in {folder:?}.");
if let Some(folder) = &self.selected_folder
&& self.selected_folder_needs_expunge
{
info!(context, "Expunge messages in {folder:?}.");
self.close().await.context("IMAP close/expunge failed")?;
info!(context, "Close/expunge succeeded.");
self.selected_folder = None;
self.selected_folder_needs_expunge = false;
self.new_mail = false;
}
self.close().await.context("IMAP close/expunge failed")?;
info!(context, "Close/expunge succeeded.");
self.selected_folder = None;
self.selected_folder_needs_expunge = false;
self.new_mail = false;
}
Ok(())
}
@@ -54,10 +55,10 @@ impl ImapSession {
async fn select_folder(&mut self, context: &Context, folder: &str) -> Result<NewlySelected> {
// if there is a new folder and the new folder is equal to the selected one, there's nothing to do.
// if there is _no_ new folder, we continue as we might want to expunge below.
if let Some(selected_folder) = &self.selected_folder {
if folder == selected_folder {
return Ok(NewlySelected::No);
}
if let Some(selected_folder) = &self.selected_folder
&& folder == selected_folder
{
return Ok(NewlySelected::No);
}
// deselect existing folder, if needed (it's also done implicitly by SELECT, however, without EXPUNGE then)
@@ -129,7 +130,7 @@ impl ImapSession {
context: &Context,
folder: &str,
create: bool,
) -> Result<bool> {
) -> anyhow::Result<bool> {
let newly_selected = if create {
self.select_or_create_folder(context, folder)
.await
@@ -146,15 +147,24 @@ impl ImapSession {
},
}
};
let transport_id = self.transport_id();
// Folders should not be selected when transport_id is not assigned yet
// because we cannot save UID validity then.
ensure_and_debug_assert!(
transport_id > 0,
"Cannot select folder when transport ID is unknown"
);
let mailbox = self
.selected_mailbox
.as_mut()
.with_context(|| format!("No mailbox selected, folder: {folder:?}"))?;
let old_uid_validity = get_uidvalidity(context, folder)
let old_uid_validity = get_uidvalidity(context, transport_id, folder)
.await
.with_context(|| format!("Failed to get old UID validity for folder {folder:?}"))?;
let old_uid_next = get_uid_next(context, folder)
let old_uid_next = get_uid_next(context, transport_id, folder)
.await
.with_context(|| format!("Failed to get old UID NEXT for folder {folder:?}"))?;
@@ -205,7 +215,7 @@ impl ImapSession {
context,
"The server illegally decreased the uid_next of folder {folder:?} from {old_uid_next} to {new_uid_next} without changing validity ({new_uid_validity}), resyncing UIDs...",
);
set_uid_next(context, folder, new_uid_next).await?;
set_uid_next(context, transport_id, folder, new_uid_next).await?;
self.resync_request_sender.try_send(()).ok();
}
@@ -224,21 +234,21 @@ impl ImapSession {
}
// UIDVALIDITY is modified, reset highest seen MODSEQ.
set_modseq(context, folder, 0).await?;
set_modseq(context, transport_id, folder, 0).await?;
// ============== uid_validity has changed or is being set the first time. ==============
let new_uid_next = new_uid_next.unwrap_or_default();
set_uid_next(context, folder, new_uid_next).await?;
set_uidvalidity(context, folder, new_uid_validity).await?;
set_uid_next(context, transport_id, folder, new_uid_next).await?;
set_uidvalidity(context, transport_id, folder, new_uid_validity).await?;
self.new_mail = true;
// Collect garbage entries in `imap` table.
context
.sql
.execute(
"DELETE FROM imap WHERE folder=? AND uidvalidity!=?",
(&folder, new_uid_validity),
"DELETE FROM imap WHERE transport_id=? AND folder=? AND uidvalidity!=?",
(transport_id, &folder, new_uid_validity),
)
.await?;
@@ -247,12 +257,7 @@ impl ImapSession {
}
info!(
context,
"uid/validity change folder {}: new {}/{} previous {}/{}.",
folder,
new_uid_next,
new_uid_validity,
old_uid_next,
old_uid_validity,
"transport {transport_id}: UID validity for folder {folder} changed from {old_uid_validity}/{old_uid_next} to {new_uid_validity}/{new_uid_next}.",
);
Ok(true)
}

View File

@@ -14,25 +14,24 @@ use crate::tools;
/// Prefetch:
/// - Message-ID to check if we already have the message.
/// - In-Reply-To and References to check if message is a reply to chat message.
/// - Chat-Version to check if a message is a chat message
/// - Autocrypt-Setup-Message to check if a message is an autocrypt setup message,
/// not necessarily sent by Delta Chat.
///
/// NB: We don't look at Chat-Version as we don't want any "better" handling for unencrypted
/// messages.
const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
MESSAGE-ID \
DATE \
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
FROM \
IN-REPLY-TO REFERENCES \
CONTENT-TYPE \
SECURE-JOIN \
SUBJECT \
CHAT-VERSION \
AUTO-SUBMITTED \
AUTOCRYPT-SETUP-MESSAGE\
)])";
#[derive(Debug)]
pub(crate) struct Session {
transport_id: u32,
pub(super) inner: ImapSession<Box<dyn SessionStream>>,
pub capabilities: Capabilities,
@@ -74,8 +73,10 @@ impl Session {
inner: ImapSession<Box<dyn SessionStream>>,
capabilities: Capabilities,
resync_request_sender: async_channel::Sender<()>,
transport_id: u32,
) -> Self {
Self {
transport_id,
inner,
capabilities,
selected_folder: None,
@@ -87,6 +88,11 @@ impl Session {
}
}
/// Returns ID of the transport for which this session was created.
pub(crate) fn transport_id(&self) -> u32 {
self.transport_id
}
pub fn can_idle(&self) -> bool {
self.capabilities.can_idle
}

View File

@@ -25,7 +25,8 @@ use crate::pgp;
use crate::qr::DCBACKUP_VERSION;
use crate::sql;
use crate::tools::{
TempPathGuard, create_folder, delete_file, get_filesuffix_lc, read_file, time, write_file,
TempPathGuard, create_folder, delete_file, get_filesuffix_lc, read_file, time, usize_to_u64,
write_file,
};
mod key_transfer;
@@ -263,14 +264,14 @@ struct ProgressReader<R> {
inner: R,
/// Number of bytes successfully read from the internal reader.
read: usize,
read: u64,
/// Total size of the backup .tar file expected to be read from the reader.
/// Used to calculate the progress.
file_size: usize,
file_size: u64,
/// Last progress emitted to avoid emitting the same progress value twice.
last_progress: usize,
last_progress: u16,
/// Context for emitting progress events.
context: Context,
@@ -281,7 +282,7 @@ impl<R> ProgressReader<R> {
Self {
inner: r,
read: 0,
file_size: file_size as usize,
file_size,
last_progress: 1,
context,
}
@@ -301,9 +302,11 @@ where
let before = buf.filled().len();
let res = this.inner.poll_read(cx, buf);
if let std::task::Poll::Ready(Ok(())) = res {
*this.read = this.read.saturating_add(buf.filled().len() - before);
*this.read = this
.read
.saturating_add(usize_to_u64(buf.filled().len() - before));
let progress = std::cmp::min(1000 * *this.read / *this.file_size, 999);
let progress = std::cmp::min(1000 * *this.read / *this.file_size, 999) as u16;
if progress > *this.last_progress {
this.context.emit_event(EventType::ImexProgress(progress));
*this.last_progress = progress;
@@ -377,7 +380,15 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
res = check_backup_version(context).await;
}
if res.is_ok() {
res = adjust_bcc_self(context).await;
// All recent backups have `bcc_self` set to "1" before export.
//
// Setting `bcc_self` to "1" on export was introduced on 2024-12-17
// in commit 21664125d798021be75f47d5b0d5006d338b4531
//
// We additionally try to set `bcc_self` to "1" after import here
// for compatibility with older backups,
// but eventually this code can be removed.
res = context.set_config(Config::BccSelf, Some("1")).await;
}
fs::remove_file(unpacked_database)
.await
@@ -482,14 +493,14 @@ struct ProgressWriter<W> {
inner: W,
/// Number of bytes successfully written into the internal writer.
written: usize,
written: u64,
/// Total size of the backup .tar file expected to be written into the writer.
/// Used to calculate the progress.
file_size: usize,
file_size: u64,
/// Last progress emitted to avoid emitting the same progress value twice.
last_progress: usize,
last_progress: u16,
/// Context for emitting progress events.
context: Context,
@@ -500,7 +511,7 @@ impl<W> ProgressWriter<W> {
Self {
inner: w,
written: 0,
file_size: file_size as usize,
file_size,
last_progress: 1,
context,
}
@@ -519,9 +530,9 @@ where
let this = self.project();
let res = this.inner.poll_write(cx, buf);
if let std::task::Poll::Ready(Ok(written)) = res {
*this.written = this.written.saturating_add(written);
*this.written = this.written.saturating_add(usize_to_u64(written));
let progress = std::cmp::min(1000 * *this.written / *this.file_size, 999);
let progress = std::cmp::min(1000 * *this.written / *this.file_size, 999) as u16;
if progress > *this.last_progress {
this.context.emit_event(EventType::ImexProgress(progress));
*this.last_progress = progress;
@@ -751,7 +762,7 @@ async fn export_database(
.to_str()
.with_context(|| format!("path {} is not valid unicode", dest.display()))?;
adjust_bcc_self(context).await?;
context.set_config(Config::BccSelf, Some("1")).await?;
context
.sql
.set_raw_config_int("backup_time", timestamp)
@@ -785,18 +796,6 @@ async fn export_database(
.await
}
/// Sets `Config::BccSelf` (and `DeleteServerAfter` to "never" in effect) if needed so that new
/// messages are present on the server after a backup restoration or available for all devices in
/// multi-device case. NB: Calling this after a backup import isn't reliable as we can crash in
/// between, but this is a problem only for old backups, new backups already have `BccSelf` set if
/// necessary.
async fn adjust_bcc_self(context: &Context) -> Result<()> {
if context.is_chatmail().await? && !context.config_exists(Config::BccSelf).await? {
context.set_config(Config::BccSelf, Some("1")).await?;
}
Ok(())
}
async fn check_backup_version(context: &Context) -> Result<()> {
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
ensure!(
@@ -983,11 +982,10 @@ mod tests {
let context1 = &TestContext::new_alice().await;
// `bcc_self` is enabled by default for test contexts. Unset it.
context1.set_config(Config::BccSelf, None).await?;
// Check that the settings are displayed correctly.
assert_eq!(
context1.get_config(Config::BccSelf).await?,
Some("1".to_string())
);
assert_eq!(
context1.get_config(Config::DeleteServerAfter).await?,
Some("0".to_string())

View File

@@ -89,6 +89,7 @@ pub mod securejoin;
mod simplify;
mod smtp;
pub mod stock_str;
pub mod storage_usage;
mod sync;
mod timesmearing;
mod token;

View File

@@ -294,7 +294,7 @@ pub async fn send_locations_to_chat(
.unwrap_or_default();
} else if 0 == seconds && is_sending_locations_before {
let stock_str = stock_str::msg_location_disabled(context).await;
chat::add_info_msg(context, chat_id, &stock_str, now).await?;
chat::add_info_msg(context, chat_id, &stock_str).await?;
}
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
@@ -849,7 +849,7 @@ async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
.context("failed to disable location streaming")?;
let stock_str = stock_str::msg_location_disabled(context).await;
chat::add_info_msg(context, chat_id, &stock_str, now).await?;
chat::add_info_msg(context, chat_id, &stock_str).await?;
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}

View File

@@ -15,16 +15,17 @@ use pin_project::pin_project;
use crate::events::{Event, EventType, Events};
use crate::net::session::SessionStream;
use crate::tools::usize_to_u64;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
#[derive(Debug)]
struct Metrics {
/// Total number of bytes read.
pub total_read: usize,
pub total_read: u64,
/// Total number of bytes written.
pub total_written: usize,
pub total_written: u64,
}
impl Metrics {
@@ -91,6 +92,11 @@ impl<S: SessionStream> AsyncRead for LoggingStream<S> {
"Read error on stream {peer_addr:?} after reading {} and writing {} bytes: {err}.",
this.metrics.total_read, this.metrics.total_written
);
tracing::event!(
::tracing::Level::WARN,
account_id = *this.account_id,
log_message
);
this.events.emit(Event {
id: *this.account_id,
typ: EventType::Warning(log_message),
@@ -98,7 +104,7 @@ impl<S: SessionStream> AsyncRead for LoggingStream<S> {
}
let n = old_remaining - buf.remaining();
this.metrics.total_read = this.metrics.total_read.saturating_add(n);
this.metrics.total_read = this.metrics.total_read.saturating_add(usize_to_u64(n));
res
}
@@ -113,7 +119,7 @@ impl<S: SessionStream> AsyncWrite for LoggingStream<S> {
let this = self.project();
let res = this.inner.poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = res {
this.metrics.total_written = this.metrics.total_written.saturating_add(n);
this.metrics.total_written = this.metrics.total_written.saturating_add(usize_to_u64(n));
}
res
}
@@ -140,7 +146,7 @@ impl<S: SessionStream> AsyncWrite for LoggingStream<S> {
let this = self.project();
let res = this.inner.poll_write_vectored(cx, bufs);
if let Poll::Ready(Ok(n)) = res {
this.metrics.total_written = this.metrics.total_written.saturating_add(n);
this.metrics.total_written = this.metrics.total_written.saturating_add(usize_to_u64(n));
}
res
}

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