Compare commits

...

79 Commits

Author SHA1 Message Date
iequidoo
221526da63 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-10-25 04:20:52 -03:00
iequidoo
1c282c01d1 feat: Don't reset key-contact status if Chat-User-Avatar header is absent (#7002)
This prepares for sending self-status only together with self-avatar in encrypted messages. The idea
is that self-status normally doesn't change frequently, so it's not a problem to re-send the whole
profile. Self-status is rather a biography, it even goes to "NOTE:" in vCards, so it's not a contact
status at a particular moment like "online" or "busy", and to see it one should go to the contact
profile.

Don't check for "Chat-Version" header though. So if a non- Delta Chat key-contact removes footer,
its "status" remains, but this shouldn't be a problem.

For unencrypted messages self-status will still be always attached except MDNs, reactions and
SecureJoin messages, so that it's visible as the message footer in other MUAs.
2025-10-25 04:20:51 -03:00
link2xt
0e30dd895f fix: fix flaky tests::verified_chats::test_verified_chat_editor_reordering and receive_imf::receive_imf_tests::test_two_group_securejoins
Flakiness was introduced in e7348a4fd8.
This change removes a call to joining_chat_id() which created a chat,
now we check for existing group chat
without creating it if it does not exist.
2025-10-25 06:47:17 +00:00
link2xt
c001a9a983 refactor(sql): add query_map_collect() 2025-10-24 18:17:15 +00:00
link2xt
5f3948b462 refactor(sql): add query_map_vec()
This also replaces some cases where flatten()
was used, effectively ignoring the errors.
2025-10-24 18:17:15 +00:00
link2xt
45a1d81805 refactor(sql): change second query_map function from FnMut to FnOnce
FnOnce is a supertrait of FnMut,
so any argument that implements FnMut
can also be used in a place where FnOnce is required.
2025-10-24 18:17:15 +00:00
Hocuri
19d7799324 feat: Be more generous with marking contacts as verified for now (#7336)
Context: PR #7116 is backwards-incompatible with versions older than
v2.21, and since the release hasn't reached all users yet, we currently
can't release from main; for details see #7326.

Issue #7326 explains how we can make this less breaking, but this only
works if many contacts are verified. So, this PR here proposes to
postpone the stricter rules for who is verified a bit:

- Set verification timeout for invite codes to 1 week (this is still
stricter than no timeout at all, which we had in the past)
- Don't reset indirect verifications yet

In a few months (when everyone has v2.22.0), we can revert the PR here,
then.

---------

Co-authored-by: l <link2xt@testrun.org>
2025-10-24 18:07:29 +00:00
iequidoo
24e18c1485 fix: Delete obsolete "configured*" keys from config table (#7171) 2025-10-24 02:15:25 -03:00
link2xt
3eb1a7dfac feat: protect the Date header 2025-10-23 15:29:14 +00:00
link2xt
2f2a147efb feat: move the messages only from INBOX and Spam folders
We do not try to delete resent messages
anymore. Previously resent messages
were distinguised by having duplicate Message-ID,
but future Date, but now we need to download
the message before we even see the Date.
We now move the message to the destination folder
but do not fetch it.

It may not be a good idea to delete
the duplicate in multi-device setups anyway,
because the device which has a message
may delete the duplicate of a message
the other device missed.

To avoid triggering IMAP busy move loop
described in the comments
we now only move the messages
from INBOX and Spam folders.
2025-10-23 15:29:14 +00:00
link2xt
f4938465c3 feat(deltachat-rpc-client): support multiple transports in resetup_account() 2025-10-23 15:14:53 +00:00
WofWca
129137b5de fix(jsonrpc): fix ChatListItem::is_self_in_group
Make it return the correct value for non-Group chats.

This also should improve performance, thanks to the fact that
we now don't have to query all the chat's contacts.
Instead we only need confirm that self-contact
is among the group members, and only when the chat type is `Group`.
2025-10-23 16:52:32 +02:00
link2xt
ec3f765727 refactor(add_transport_from_qr): do not set deprecated config values 2025-10-23 14:04:53 +00:00
link2xt
a743ad9490 feat: do not send Autocrypt in MDNs 2025-10-23 13:49:40 +00:00
link2xt
89315b8ef2 refactor: stop using deprecated Account.configure() 2025-10-23 13:41:57 +00:00
link2xt
e7348a4fd8 feat: do not run SecureJoin if we are already in the group 2025-10-23 06:53:22 +00:00
link2xt
c68244692d api(jsonrpc): restore protect argument for create_group_chat
It was removed in 498a831873
Restoring as optional argument to avoid breaking compatibility.
2025-10-22 18:14:21 +00:00
link2xt
3c93f61b4d fix: migrate from tokio-tar to astral-tokio-tar
tokio-tar is unmaintained and has unpatched CVE-2025-62518.
More details on CVE are in <https://edera.dev/stories/tarmageddon>.
tokio-tar is only used for transferring backups
and worst case is that by manually inspecting
a carefully crafted backup user will not see
the same files as get unpacked when importing a backup.
2025-10-22 16:09:21 +00:00
Hocuri
51b9e86d71 Opt-in weekly sending of statistics (#6851)
This way, the statistics / self-reporting bot will be made into an
opt-in regular sending of statistics, where you enable the setting once
and then they will be sent automatically. The statistics will be sent to
a bot, so that the user can see exactly which data is being sent, and
how often. The chat will be archived and muted by default, so that it
doesn't disturb the user.

The collected statistics will focus on the public-key-verification that
is performed while scanning a QR code. Later on, we can add more
statistics to collect.

**Context:**

_This is just to give a rough idea; I would need to write a lot more
than a few paragraphs in order to fully explain all the context here_.

End-to-end encrypted messengers are generally susceptible to MitM
attacks. In order to mitigate against this, messengers offer some way of
verifying the chat partner's public key. However, numerous studies found
that most popular messengers implement this public-key-verification in a
way that is not understood by users, and therefore ineffective - [a 2021
"State of Knowledge" paper
concludes:](https://dl.acm.org/doi/pdf/10.1145/3558482.3581773)

> Based on our evaluation, we have determined that all current E2EE
apps, particularly when operating in opportunistic E2EE mode, are
incapable of repelling active man-in-the-middle (MitM) attacks. In
addition, we find that none of the current E2EE apps provide better and
more usable [public key verification] ceremonies, resulting in insecure
E2EE communications against active MitM attacks.

This is why Delta Chat tries to go a different route: When the user
scans a QR code (regardless of whether the QR code creates a 1:1 chat,
invites to a group, or subscribes to a broadcast channel), a
public-key-verification is performed in the background, without the user
even having to know about this.

The statistics collected here are supposed to tell us whether Delta Chat
succeeds to nudge the users into using QR codes in a way that is secure
against MitM attacks.

**Plan for statistics-sending:**

- [x] Get this PR reviewed and merged (but don't make it available in
the UI yet; if Android wants to make a release in the meantime, I will
create a PR that removes the option there)
- [x] Set the interval to 1 week again (right now, it's 1 minute for
testing)
- [ ] Write something for people who are interested in what exactly we
count, and link to it (see `TODO[blog post]` in the code)
- [ ] Prepare a short survey for participants
- [ ] Fine-tune the texts at
https://github.com/deltachat/deltachat-android/pull/3794, and get it
reviewed and merged
- [ ] After the next release, ask people to enable the
statistics-sending
2025-10-21 15:29:21 +02:00
Simon Laux
347938a9f9 refactor: jsonrpc rename change casing in names of jsonrpc structs/enums to comply with rust naming conventions. (#7324) 2025-10-21 13:49:16 +02:00
Hocuri
9897ef2e9b refactor: Remove error stock strings that are rarely used these days (#7327)
This removes the DC_STR_CANTDECRYPT_MSG_BODY and
DC_STR_CANT_DECRYPT_OUTGOING_MSGS stock strings.

See
https://github.com/deltachat/deltachat-android/pull/3956#issuecomment-3425349195
for reasoning.
2025-10-21 09:04:11 +00:00
link2xt
2f34a740c7 build(nix): fix build of deltachat-rpc-server-x86_64-darwin 2025-10-20 20:29:47 +00:00
iequidoo
fc81cef113 refactor: Rename chat::create_group_chat() to create_group()
If we use modules (which are actually namespaces), we can use shorter names. Another approach is to
only use modules for internal code incapsulation and use full names like deltachat-ffi does.
2025-10-20 04:19:22 -03:00
iequidoo
04c2585c27 feat: Synchronize encrypted groups creation across devices (#7001)
Unencrypted groups don't have grpid since key-contacts were merged, so we don't sync them for now.
2025-10-20 04:19:22 -03:00
Simon Laux
59fac54f7b test: Add unique offsets to ids generated by TestContext to increase test correctness (#7297)
and fix the mistakes in tests that get discovered by this.

closes #6799
2025-10-19 17:08:23 +00:00
Simon Laux
65b61efb31 build: ignore configuration for the zed editor (#7322) 2025-10-19 17:03:39 +00:00
link2xt
afc74b0829 fix: do not allow sync item timestamps to be in the future 2025-10-19 11:35:10 +00:00
link2xt
2481a0f48e feat: reset all indirect verifications 2025-10-19 11:35:09 +00:00
link2xt
6c24edb40d feat: do not mark Bob as verified if auth token is old 2025-10-19 11:35:09 +00:00
link2xt
e4178789da refactor: remove ProtectionStatus 2025-10-19 11:35:09 +00:00
link2xt
b417ba86bc api!: remove Chat.is_protected() 2025-10-19 11:35:09 +00:00
link2xt
498a831873 api!: remove APIs to create protected chats
Create unprotected group in test_create_protected_grp_multidev
The test is renamed accordingly.

SystemMessage::ChatE2ee is added in encrypted groups
regardless of whether they are protected or not.
Previously new encrypted unprotected groups
had no message saying that messages are end-to-end encrypted
at all.
2025-10-19 11:35:09 +00:00
link2xt
c6722d36de api!: remove public APIs to check if the chat is protected 2025-10-19 11:35:09 +00:00
link2xt
90f0d5c060 api: make dc_chat_is_protected always return 0 2025-10-19 11:35:09 +00:00
link2xt
90ec2f2518 docs: document Autocrypt-Gossip _verified attribute 2025-10-19 11:35:09 +00:00
link2xt
5b66535134 feat: verify contacts via Autocrypt-Gossip
This mechanism replaces `Chat-Verified` header.
New parameter `_verified=1` in `Autocrypt-Gossip`
header marks that the sender has the gossiped key
verified.

Using `_verified=1` instead of `_verified`
because it is less likely to cause troubles
with existing Autocrypt header parsers.
This is also how https://www.rfc-editor.org/rfc/rfc2045
defines parameter syntax.
2025-10-19 11:35:09 +00:00
link2xt
eea848f72b fix: don't ignore QR token timestamp from sync messages 2025-10-19 11:35:09 +00:00
link2xt
214a1d3e2d feat: do not resolve MX records during configuration
MX record lookup was only used to detect Google Workspace domains.
They can still be configured manually.
We anyway do not want to encourage creating new profiles
with Google Workspace as we don't have Gmail OAUTH2 token anymore
and new users can more easily onboard with a chatmail relay.
2025-10-18 18:09:56 +00:00
link2xt
e270a502d1 refactor: remove invalid Gmail OAuth2 tokens
They were already unused since
<https://github.com/chatmail/provider-db/pull/310>
2025-10-18 18:09:56 +00:00
iequidoo
b863345600 test(rpc-client): vCard color is the same as the contact color (#7294)
This tests "fix(jsonrpc): Use Core's logic for computing VcardContact.color".
2025-10-17 14:46:57 -03:00
link2xt
61b49a9339 Merge tag 'v2.22.0'
Release 2.22.0
2025-10-17 10:53:12 +00:00
link2xt
41c80cf3f2 chore(release): prepare for 2.22.0 2025-10-17 10:51:26 +00:00
l
6fd3645360 Merge pull request #7320 from chatmail/link2xt/dont-notify-contact-request-calls 2025-10-17 10:45:41 +00:00
link2xt
b812d0a7f7 fix: do not notify about incoming calls for contact requests and blocked contacts 2025-10-17 10:30:46 +00:00
link2xt
e8a4c9237d test: accept the chat with the caller before accepting calls 2025-10-17 10:30:46 +00:00
link2xt
5256013615 feat: protect Autocrypt header 2025-10-16 23:34:44 +00:00
link2xt
9826c28581 feat: anonymize OpenPGP recipients 2025-10-16 23:34:03 +00:00
link2xt
9ceceebdc3 ci: set 7 days cooldown on Dependabot updates
This fixes the warning
<https://docs.zizmor.sh/audits/#dependabot-cooldown>
and avoids updating to freshly published dependencies
that are more likely to be unpublished.
2025-10-16 20:50:24 +00:00
link2xt
187d913f84 ci: pin GitHub action astral-sh/setup-uv
Pinned version corresponds to the current v7.1.0 tag.
2025-10-16 20:50:03 +00:00
link2xt
4a0b180d86 chore(release): prepare for 2.21.0 2025-10-16 18:18:10 +00:00
dependabot[bot]
6fa6055912 chore(deps): bump github/codeql-action from 3 to 4 (#7304) 2025-10-16 02:24:19 +00:00
dependabot[bot]
667995cde4 chore(cargo): bump async_zip from 0.0.17 to 0.0.18 (#7257) 2025-10-15 21:54:02 +00:00
link2xt
1e0def87fd feat: cache tile.openstreetmap.org tiles for 7 days 2025-10-15 17:07:55 +00:00
link2xt
a219e5ee8c feat: set User-Agent for tile.openstreetmap.org requests 2025-10-15 17:07:55 +00:00
link2xt
8070dfcc82 refactor(mimeparser): store only one signature fingerprint
Messages are normally not signed with more than one key
and in this case we pick an arbitrary signature later anyway.
2025-10-15 16:45:36 +00:00
link2xt
176a89bd03 feat(deltachat-repl): add send-sync command
This allows to send send messages when disconnected
using `send_msg_sync` to create a dedicated connection
and send a single message.

The command can be used for measuring
the duration of SMTP connection establishment.
2025-10-15 16:45:19 +00:00
link2xt
dd8dd2f95c build(nix): remove unused dependencies
darwin.apple_sdk.frameworks is deprecated and does nothing according to
<https://nixos.org/manual/nixpkgs/stable/#sec-darwin-legacy-frameworks>

libiconv dependency also does not seem to be needed for `libdeltachat`,
I have checked that `nix build .#libdeltachat` still succeeds on macOS.
2025-10-15 16:43:54 +00:00
link2xt
eb1bd1d200 feat: TLS 1.3 session resumption 2025-10-15 16:41:50 +00:00
link2xt
460d2f3c2a refactor: pass ALPN around as &str 2025-10-15 16:41:50 +00:00
link2xt
0ab10f99fd refactor: use rustls reexported from tokio_rustls 2025-10-15 16:41:50 +00:00
iequidoo
377f57f1c3 fix(jsonrpc): Use Core's logic for computing VcardContact.color (#7294)
Before, the color was computed from the address, but as we've switched to fingerprint-based contact
colors, this logic became stale. Now `deltachat::contact::get_color()` is used. A test would be nice
to have, but as now all the logic is in Core, this isn't critical as there are Core tests at least.
2025-10-14 05:43:10 -03:00
iequidoo
caf5f1f619 fix: Remove Exif with non-fatal errors from images
NB: This still doesn't help with detecting Exif in PNGs unfortunately.
2025-10-14 02:13:03 -03:00
dependabot[bot]
d9ff85a202 chore(deps): bump cachix/install-nix-action from 31.7.0 to 31.8.0
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.7.0 to 31.8.0.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md)
- [Commits](9280e7aca8...7ab6e7fd29)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-14 00:59:07 +00:00
link2xt
f180a7c024 test: test expiration of non-ephemeral message with unknown viewtype 2025-10-13 18:51:46 +00:00
link2xt
7fac9332e1 test: test expiration of ephemeral messages with unknown viewtype 2025-10-13 18:51:46 +00:00
link2xt
8dd7c42f69 chore(release): prepare for 2.20.0 2025-10-13 11:34:44 +00:00
B. Petersen
b542eeecc0 fix: accept unknown viewtype in delete-old-messages loop 2025-10-13 13:29:57 +02:00
B. Petersen
bee8295daa fix: accept unknown viewtype in ephemeral loop 2025-10-13 13:29:57 +02:00
link2xt
ab9fd3d5ed chore(release): prepare for 2.19.0 2025-10-12 17:33:43 +00:00
link2xt
cc54a3feda fix: do not try to process calls from partial messages
Any control information from the message
should only be downloaded when the message
is fully downloaded to avoid processing it twice.
Besides, "partial" messages may actually be full messages
with an error that are only processed as partial
to add a message bubble allowing to download the message later.
2025-10-12 17:20:21 +00:00
link2xt
94984f35ec fix: do not fail to receive call accepted/ended messages referring to non-call Message-ID
In-Reply-To may refer to non-call message
as we do not control the sender.
It may also happen that call message
was received by older version and processed
as text, in which case correct In-Reply-To
appears to be referring to the text message.
2025-10-12 17:20:21 +00:00
iequidoo
0e47e89d63 fix: Emit AccountsItemChanged when own key is generated/imported, use gray self-color until that (#7296)
Emitting an `AccountsItemChanged` event is needed for UIs to know when `Contact::get_color()` starts
returning the true color for `SELF`. Before, an address-based color was returned for a new account
which was changing to a fingerprint-based color e.g. on app restart. Now the self-color is our
favorite gray until own keypair is generated or imported e.g. via ASM.
2025-10-12 14:17:24 -03:00
link2xt
2d7dc7a1be fix: do not fail to fully download previously trashed messages 2025-10-12 13:12:00 +00:00
iequidoo
4d76a5b599 refactor: set_chat_profile_image(): Remove !chat.is_mailing_list() check 2025-10-12 01:41:31 -03:00
link2xt
87035ff744 feat: slightly increase saturation of colors
It really depends on the screen,
but on Android phones with higher DPI
than laptop screen the contrast
looks a bit too low.

It should not be increased too high
because of clipping.
2025-10-09 20:28:21 +00:00
iequidoo
e0d123f732 chore(cargo): bump quick-xml from 0.37.5 to 0.38.3 2025-10-09 14:03:56 -03:00
link2xt
8eddcfc9d2 ci: update to Python 3.14
I have not updated Python interpreters for legacy bindings
in scripts/run_all.sh because I have not checked
if manylinux images already exist and work for building wheels.
Legacy Python bindings are deprecated
and we don't publish new releases for them.
2025-10-09 15:55:59 +00:00
Simon Laux
af58b86b60 refactor: Use variables directly in formatted strings (#7284)
made with `cargo clippy --all --fix` then manually reviewed to ensure
this was the only thing that changed.
2025-10-09 15:26:59 +00:00
Simon Laux
00ae7ce33c add nodejs to nix dev env (#7283)
it is required to build jsonrpc client npm package and for the stdio
server npm package
2025-10-09 14:27:12 +00:00
142 changed files with 4435 additions and 3024 deletions

View File

@@ -7,6 +7,8 @@ updates:
commit-message:
prefix: "chore(cargo)"
open-pull-requests-limit: 50
cooldown:
default-days: 7
# Keep GitHub Actions up to date.
# <https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot>
@@ -14,3 +16,5 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7

View File

@@ -228,9 +228,9 @@ jobs:
include:
# Currently used Rust version.
- os: ubuntu-latest
python: 3.13
python: 3.14
- os: macos-latest
python: 3.13
python: 3.14
# PyPy tests
- os: ubuntu-latest
@@ -281,11 +281,11 @@ jobs:
matrix:
include:
- os: ubuntu-latest
python: 3.13
python: 3.14
- os: macos-latest
python: 3.13
python: 3.14
- os: windows-latest
python: 3.13
python: 3.14
# PyPy tests
- os: ubuntu-latest

View File

@@ -34,7 +34,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -58,7 +58,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -109,7 +109,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -136,7 +136,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v5

View File

@@ -25,7 +25,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- run: nix fmt flake.nix -- --check
build:
@@ -84,7 +84,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -95,14 +95,15 @@ jobs:
matrix:
installable:
- deltachat-rpc-server
- deltachat-rpc-server-x86_64-darwin
# Fails to bulid
# Fails to build
# because of <https://github.com/NixOS/nixpkgs/issues/413910>.
# - deltachat-rpc-server-aarch64-darwin
# - deltachat-rpc-server-x86_64-darwin
steps:
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- run: nix build .#${{ matrix.installable }}

View File

@@ -18,7 +18,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary

View File

@@ -36,7 +36,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -55,7 +55,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat

View File

@@ -19,13 +19,13 @@ jobs:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d
- name: Run zizmor
run: uvx zizmor --format sarif . > results.sarif
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
category: zizmor

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@ deltachat-ffi/xml
coverage/
.DS_Store
.vscode
.zed
python/accounts.txt
python/all-testaccounts.txt
tmp/

View File

@@ -1,5 +1,87 @@
# Changelog
## [2.22.0] - 2025-10-17
### Fixes
- Do not notify about incoming calls for contact requests and blocked contacts.
### Tests
- Accept the chat with the caller before accepting calls.
## [2.21.0] - 2025-10-16
### Build system
- nix: Remove unused dependencies.
### Features / Changes
- TLS 1.3 session resumption.
- REPL: Add send-sync command.
- Set `User-Agent` for tile.openstreetmap.org requests.
- Cache tile.openstreetmap.org tiles for 7 days.
### Fixes
- Remove Exif with non-fatal errors from images.
- jsonrpc: Use Core's logic for computing VcardContact.color ([#7294](https://github.com/chatmail/core/pull/7294)).
### Miscellaneous Tasks
- deps: Bump cachix/install-nix-action from 31.7.0 to 31.8.0.
- cargo: Bump async_zip from 0.0.17 to 0.0.18 ([#7257](https://github.com/chatmail/core/pull/7257)).
- deps: Bump github/codeql-action from 3 to 4 ([#7304](https://github.com/chatmail/core/pull/7304)).
### Refactor
- Use rustls reexported from tokio_rustls.
- Pass ALPN around as &str.
- mimeparser: Store only one signature fingerprint.
### Tests
- Test expiration of ephemeral messages with unknown viewtype.
- Test expiration of non-ephemeral message with unknown viewtype.
## [2.20.0] - 2025-10-13
This release fixes a bug that resulted in ephemeral loop getting stuck in infinite loop
when trying to delete a message with unknown viewtype.
### Fixes
- Accept unknown viewtype in ephemeral loop.
- Accept unknown viewtype in delete-old-messages loop.
## [2.19.0] - 2025-10-12
### Features / Changes
- Slightly increase saturation of colors.
### Fixes
- Do not fail to receive call accepted/ended messages referring to non-call Message-ID.
- Do not fail to fully download previously trashed messages.
- Emit AccountsItemChanged when own key is generated/imported, use gray self-color until that ([#7296](https://github.com/chatmail/core/pull/7296)).
- Do not try to process calls from partial messages.
### CI
- Update to Python 3.14.
### Refactor
- Use variables directly in formatted strings ([#7284](https://github.com/chatmail/core/pull/7284)).
- Set_chat_profile_image(): Remove !chat.is_mailing_list() check.
### Miscellaneous Tasks
- cargo: Bump quick-xml from 0.37.5 to 0.38.3.
- Add nodejs to nix dev env ([#7283](https://github.com/chatmail/core/pull/7283))
## [2.18.0] - 2025-10-08
### API-Changes
@@ -6882,3 +6964,7 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.16.0]: https://github.com/chatmail/core/compare/v2.15.0..v2.16.0
[2.17.0]: https://github.com/chatmail/core/compare/v2.16.0..v2.17.0
[2.18.0]: https://github.com/chatmail/core/compare/v2.17.0..v2.18.0
[2.19.0]: https://github.com/chatmail/core/compare/v2.18.0..v2.19.0
[2.20.0]: https://github.com/chatmail/core/compare/v2.19.0..v2.20.0
[2.21.0]: https://github.com/chatmail/core/compare/v2.20.0..v2.21.0
[2.22.0]: https://github.com/chatmail/core/compare/v2.21.0..v2.22.0

83
Cargo.lock generated
View File

@@ -198,6 +198,21 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "astral-tokio-tar"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5"
dependencies = [
"filetime",
"futures-core",
"libc",
"portable-atomic",
"rustc-hash",
"tokio",
"tokio-stream",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -346,15 +361,15 @@ dependencies = [
[[package]]
name = "async_zip"
version = "0.0.17"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52"
checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6"
dependencies = [
"async-compression",
"crc32fast",
"futures-lite",
"pin-project",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tokio-util",
]
@@ -1289,9 +1304,10 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.18.0"
version = "2.22.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
"async-broadcast",
"async-channel 2.5.0",
"async-imap",
@@ -1316,7 +1332,6 @@ dependencies = [
"futures",
"futures-lite",
"hex",
"hickory-resolver",
"http-body-util",
"humansize",
"hyper",
@@ -1346,7 +1361,6 @@ dependencies = [
"ratelimit",
"regex",
"rusqlite",
"rustls",
"rustls-pki-types",
"sanitize-filename",
"sdp",
@@ -1368,7 +1382,6 @@ dependencies = [
"tokio-io-timeout",
"tokio-rustls",
"tokio-stream",
"tokio-tar",
"tokio-util",
"toml",
"tracing",
@@ -1399,7 +1412,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.18.0"
version = "2.22.0"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1421,7 +1434,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.18.0"
version = "2.22.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1437,7 +1450,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.18.0"
version = "2.22.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1466,7 +1479,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.18.0"
version = "2.22.0"
dependencies = [
"anyhow",
"deltachat",
@@ -2015,14 +2028,14 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.23"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.4.1",
"windows-sys 0.52.0",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
@@ -3271,6 +3284,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.9.1",
"libc",
"redox_syscall 0.5.12",
]
[[package]]
@@ -4639,9 +4653,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
version = "0.38.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
dependencies = [
"memchr",
]
@@ -4860,15 +4874,6 @@ dependencies = [
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
@@ -6150,21 +6155,6 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "tokio-tar"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5714c010ca3e5c27114c1cdeb9d14641ace49874aa5626d7149e47aedace75"
dependencies = [
"filetime",
"futures-core",
"libc",
"redox_syscall 0.3.5",
"tokio",
"tokio-stream",
"xattr",
]
[[package]]
name = "tokio-tfo"
version = "0.3.1"
@@ -7277,17 +7267,6 @@ dependencies = [
"time",
]
[[package]]
name = "xattr"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909"
dependencies = [
"libc",
"linux-raw-sys 0.4.14",
"rustix 0.38.44",
]
[[package]]
name = "xml-rs"
version = "0.8.25"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.18.0"
version = "2.22.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"
@@ -47,7 +47,7 @@ async-channel = { workspace = true }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.17", default-features = false, features = ["deflate", "tokio-fs"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
blake3 = "1.8.2"
brotli = { version = "8", default-features=false, features = ["std"] }
@@ -61,7 +61,6 @@ fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
hex = "0.4.0"
hickory-resolver = "0.25.2"
http-body-util = "0.1.3"
humansize = "2"
hyper = "1"
@@ -82,12 +81,11 @@ percent-encoding = "2.3"
pgp = { version = "0.17.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = "0.37"
quick-xml = { version = "0.38", features = ["escape-html"] }
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rustls-pki-types = "1.12.0"
rustls = { version = "0.23.22", default-features = false }
sanitize-filename = { workspace = true }
sdp = "0.8.0"
serde_json = { workspace = true }
@@ -105,7 +103,7 @@ thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] }
tokio-tar = { version = "0.3" } # TODO: integrate tokio into async-tar
astral-tokio-tar = { version = "0.5.6", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"

View File

@@ -68,7 +68,7 @@ impl ContactAddress {
pub fn new(s: &str) -> Result<Self> {
let addr = addr_normalize(s);
if !may_be_valid_addr(&addr) {
bail!("invalid address {:?}", s);
bail!("invalid address {s:?}");
}
Ok(Self(addr.to_string()))
}
@@ -257,16 +257,16 @@ impl EmailAddress {
.chars()
.any(|c| c.is_whitespace() || c == '<' || c == '>')
{
bail!("Email {:?} must not contain whitespaces, '>' or '<'", input);
bail!("Email {input:?} must not contain whitespaces, '>' or '<'");
}
match &parts[..] {
[domain, local] => {
if local.is_empty() {
bail!("empty string is not valid for local part in {:?}", input);
bail!("empty string is not valid for local part in {input:?}");
}
if domain.is_empty() {
bail!("missing domain after '@' in {:?}", input);
bail!("missing domain after '@' in {input:?}");
}
if domain.ends_with('.') {
bail!("Domain {domain:?} should not contain the dot in the end");
@@ -276,7 +276,7 @@ impl EmailAddress {
domain: (*domain).to_string(),
})
}
_ => bail!("Email {:?} must contain '@' character", input),
_ => bail!("Email {input:?} must contain '@' character"),
}
}
}

View File

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

View File

@@ -1763,9 +1763,7 @@ dc_chat_t* dc_get_chat (dc_context_t* context, uint32_t ch
*
* @memberof dc_context_t
* @param context The context object.
* @param protect If set to 1 the function creates group with protection initially enabled.
* Only verified members are allowed in these groups
* and end-to-end-encryption is always enabled.
* @param protect Deprecated 2025-08-31, ignored.
* @param name The name of the group chat to create.
* The name may be changed later using dc_set_chat_name().
* To find out the name of a group later, see dc_chat_get_name()
@@ -3890,18 +3888,12 @@ int dc_chat_can_send (const dc_chat_t* chat);
/**
* Check if a chat is protected.
*
* Only verified contacts
* as determined by dc_contact_is_verified()
* can be added to protected chats.
*
* Protected chats are created using dc_create_group_chat()
* by setting the 'protect' parameter to 1.
* Deprecated, always returns 0.
*
* @memberof dc_chat_t
* @param chat The chat object.
* @return 1=chat protected, 0=chat is not protected.
* @return Always 0.
* @deprecated 2025-09-09
*/
int dc_chat_is_protected (const dc_chat_t* chat);
@@ -5350,11 +5342,9 @@ dc_provider_t* dc_provider_new_from_email (const dc_context_t* conte
/**
* Create a provider struct for the given e-mail address by local and DNS lookup.
* Create a provider struct for the given e-mail address by local lookup.
*
* First lookup is done from the local database as of dc_provider_new_from_email().
* If the first lookup fails, an additional DNS lookup is done,
* trying to figure out the provider belonging to custom domains.
* DNS lookup is not used anymore and this function is deprecated.
*
* @memberof dc_provider_t
* @param context The context object.
@@ -5362,6 +5352,7 @@ dc_provider_t* dc_provider_new_from_email (const dc_context_t* conte
* @return A dc_provider_t struct which can be used with the dc_provider_get_*
* accessor functions. If no provider info is found, NULL will be
* returned.
* @deprecated 2025-10-17 use dc_provider_new_from_email() instead.
*/
dc_provider_t* dc_provider_new_from_email_with_dns (const dc_context_t* context, const char* email);
@@ -6965,11 +6956,6 @@ void dc_event_unref(dc_event_t* event);
/// Used to build the string returned by dc_get_contact_encrinfo().
#define DC_STR_ENCR_NONE 28
/// "This message was encrypted for another setup."
///
/// Used as message text if decryption fails.
#define DC_STR_CANTDECRYPT_MSG_BODY 29
/// "Fingerprints"
///
/// Used to build the string returned by dc_get_contact_encrinfo().
@@ -7687,12 +7673,6 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` will be replaced by the provider's domain.
#define DC_STR_INVALID_UNENCRYPTED_MAIL 174
/// "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions."
///
/// Added to the device chat if could not decrypt a new outgoing message (i.e. not when fetching
/// existing messages). But no more than once a day.
#define DC_STR_CANT_DECRYPT_OUTGOING_MSGS 175
/// "You reacted %1$s to '%2$s'"
///
/// `%1$s` will be replaced by the reaction, usually an emoji

View File

@@ -22,7 +22,7 @@ use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime};
use anyhow::Context as _;
use deltachat::chat::{ChatId, ChatVisibility, MessageListOptions, MuteDuration, ProtectionStatus};
use deltachat::chat::{ChatId, ChatVisibility, MessageListOptions, MuteDuration};
use deltachat::constants::DC_MSG_ID_LAST_SPECIAL;
use deltachat::contact::{Contact, ContactId, Origin};
use deltachat::context::{Context, ContextBuilder};
@@ -1721,7 +1721,7 @@ pub unsafe extern "C" fn dc_get_chat(context: *mut dc_context_t, chat_id: u32) -
#[no_mangle]
pub unsafe extern "C" fn dc_create_group_chat(
context: *mut dc_context_t,
protect: libc::c_int,
_protect: libc::c_int,
name: *const libc::c_char,
) -> u32 {
if context.is_null() || name.is_null() {
@@ -1729,22 +1729,12 @@ pub unsafe extern "C" fn dc_create_group_chat(
return 0;
}
let ctx = &*context;
let Some(protect) = ProtectionStatus::from_i32(protect)
.context("Bad protect-value for dc_create_group_chat()")
.log_err(ctx)
.ok()
else {
return 0;
};
block_on(async move {
chat::create_group_chat(ctx, protect, &to_string_lossy(name))
.await
.context("Failed to create group chat")
.log_err(ctx)
.map(|id| id.to_u32())
.unwrap_or(0)
})
block_on(chat::create_group(ctx, &to_string_lossy(name)))
.context("Failed to create group chat")
.log_err(ctx)
.map(|id| id.to_u32())
.unwrap_or(0)
}
#[no_mangle]
@@ -3206,13 +3196,8 @@ pub unsafe extern "C" fn dc_chat_can_send(chat: *mut dc_chat_t) -> libc::c_int {
}
#[no_mangle]
pub unsafe extern "C" fn dc_chat_is_protected(chat: *mut dc_chat_t) -> libc::c_int {
if chat.is_null() {
eprintln!("ignoring careless call to dc_chat_is_protected()");
return 0;
}
let ffi_chat = &*chat;
ffi_chat.chat.is_protected() as libc::c_int
pub extern "C" fn dc_chat_is_protected(_chat: *mut dc_chat_t) -> libc::c_int {
0
}
#[no_mangle]
@@ -4661,13 +4646,9 @@ pub unsafe extern "C" fn dc_provider_new_from_email(
let ctx = &*context;
match block_on(provider::get_provider_info_by_addr(
ctx,
addr.as_str(),
true,
))
.log_err(ctx)
.unwrap_or_default()
match provider::get_provider_info_by_addr(addr.as_str())
.log_err(ctx)
.unwrap_or_default()
{
Some(provider) => provider,
None => ptr::null_mut(),
@@ -4686,25 +4667,13 @@ pub unsafe extern "C" fn dc_provider_new_from_email_with_dns(
let addr = to_string_lossy(addr);
let ctx = &*context;
let proxy_enabled = block_on(ctx.get_config_bool(config::Config::ProxyEnabled))
.context("Can't get config")
.log_err(ctx);
match proxy_enabled {
Ok(proxy_enabled) => {
match block_on(provider::get_provider_info_by_addr(
ctx,
addr.as_str(),
proxy_enabled,
))
.log_err(ctx)
.unwrap_or_default()
{
Some(provider) => provider,
None => ptr::null_mut(),
}
}
Err(_) => ptr::null_mut(),
match provider::get_provider_info_by_addr(addr.as_str())
.log_err(ctx)
.unwrap_or_default()
{
Some(provider) => provider,
None => ptr::null_mut(),
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.18.0"
version = "2.22.0"
description = "DeltaChat JSON-RPC API"
edition = "2021"
license = "MPL-2.0"

View File

@@ -12,7 +12,6 @@ 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,
ProtectionStatus,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::Config;
@@ -55,19 +54,19 @@ use types::events::Event;
use types::http::HttpResponse;
use types::message::{MessageData, MessageObject, MessageReadReceipt};
use types::provider_info::ProviderInfo;
use types::reactions::JSONRPCReactions;
use types::reactions::JsonrpcReactions;
use types::webxdc::WebxdcMessageInfo;
use self::types::message::{MessageInfo, MessageLoadResult};
use self::types::{
chat::{BasicChat, JSONRPCChatVisibility, MuteDuration},
chat::{BasicChat, JsonrpcChatVisibility, MuteDuration},
location::JsonrpcLocation,
message::{
JSONRPCMessageListItem, MessageNotificationInfo, MessageSearchResult, MessageViewtype,
JsonrpcMessageListItem, MessageNotificationInfo, MessageSearchResult, MessageViewtype,
},
};
use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
use crate::api::types::qr::QrObject;
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
#[derive(Debug)]
struct AccountState {
@@ -126,7 +125,7 @@ impl CommandApi {
.read()
.await
.get_account(id)
.ok_or_else(|| anyhow!("account with id {} not found", id))?;
.ok_or_else(|| anyhow!("account with id {id} not found"))?;
Ok(sc)
}
@@ -308,8 +307,7 @@ impl CommandApi {
Ok(Account::from_context(&ctx, account_id).await?)
} else {
Err(anyhow!(
"account with id {} doesn't exist anymore",
account_id
"account with id {account_id} doesn't exist anymore"
))
}
}
@@ -337,21 +335,10 @@ impl CommandApi {
/// instead of the domain.
async fn get_provider_info(
&self,
account_id: u32,
_account_id: u32,
email: String,
) -> Result<Option<ProviderInfo>> {
let ctx = self.get_context(account_id).await?;
let proxy_enabled = ctx
.get_config_bool(deltachat::config::Config::ProxyEnabled)
.await?;
let provider_info = get_provider_info(
&ctx,
email.split('@').next_back().unwrap_or(""),
proxy_enabled,
)
.await;
let provider_info = get_provider_info(email.split('@').next_back().unwrap_or(""));
Ok(ProviderInfo::from_dc_type(provider_info))
}
@@ -394,11 +381,6 @@ impl CommandApi {
Ok(BlobObject::create_and_deduplicate(&ctx, file, file)?.to_abs_path())
}
async fn draft_self_report(&self, account_id: u32) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.draft_self_report().await?.to_u32())
}
/// Sets the given configuration key.
async fn set_config(&self, account_id: u32, key: String, value: Option<String>) -> Result<()> {
let ctx = self.get_context(account_id).await?;
@@ -897,6 +879,38 @@ impl CommandApi {
Ok(chat_id.to_u32())
}
/// Like `secure_join()`, but allows to pass a source and a UI-path.
/// You only need this if your UI has an option to send statistics
/// to Delta Chat's developers.
///
/// **source**: The source where the QR code came from.
/// E.g. a link that was clicked inside or outside Delta Chat,
/// the "Paste from Clipboard" action,
/// the "Load QR code as image" action,
/// or a QR code scan.
///
/// **uipath**: Which UI path did the user use to arrive at the QR code screen.
/// If the SecurejoinSource was ExternalLink or InternalLink,
/// pass `None` here, because the QR code screen wasn't even opened.
/// ```
async fn secure_join_with_ux_info(
&self,
account_id: u32,
qr: String,
source: Option<SecurejoinSource>,
uipath: Option<SecurejoinUiPath>,
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let chat_id = securejoin::join_securejoin_with_ux_info(
&ctx,
&qr,
source.map(Into::into),
uipath.map(Into::into),
)
.await?;
Ok(chat_id.to_u32())
}
async fn leave_group(&self, account_id: u32, chat_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
remove_contact_from_chat(&ctx, ChatId::new(chat_id), ContactId::SELF).await
@@ -980,17 +994,16 @@ impl CommandApi {
/// To check, if a chat is still unpromoted, you can look at the `is_unpromoted` property of `BasicChat` or `FullChat`.
/// This may be useful if you want to show some help for just created groups.
///
/// @param protect If set to 1 the function creates group with protection initially enabled.
/// Only verified members are allowed in these groups
async fn create_group_chat(&self, account_id: u32, name: String, protect: bool) -> Result<u32> {
/// `protect` argument is deprecated as of 2025-10-22 and is left for compatibility.
/// Pass `false` here.
async fn create_group_chat(
&self,
account_id: u32,
name: String,
_protect: bool,
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let protect = match protect {
true => ProtectionStatus::Protected,
false => ProtectionStatus::Unprotected,
};
chat::create_group_ex(&ctx, Some(protect), &name)
.await
.map(|id| id.to_u32())
chat::create_group(&ctx, &name).await.map(|id| id.to_u32())
}
/// Create a new unencrypted group chat.
@@ -999,7 +1012,7 @@ impl CommandApi {
/// address-contacts.
async fn create_group_chat_unencrypted(&self, account_id: u32, name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
chat::create_group_ex(&ctx, None, &name)
chat::create_group_unencrypted(&ctx, &name)
.await
.map(|id| id.to_u32())
}
@@ -1071,7 +1084,7 @@ impl CommandApi {
&self,
account_id: u32,
chat_id: u32,
visibility: JSONRPCChatVisibility,
visibility: JsonrpcChatVisibility,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
@@ -1276,7 +1289,7 @@ impl CommandApi {
chat_id: u32,
info_only: bool,
add_daymarker: bool,
) -> Result<Vec<JSONRPCMessageListItem>> {
) -> Result<Vec<JsonrpcMessageListItem>> {
let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ex(
&ctx,
@@ -1290,7 +1303,7 @@ impl CommandApi {
Ok(msg
.iter()
.map(|chat_item| (*chat_item).into())
.collect::<Vec<JSONRPCMessageListItem>>())
.collect::<Vec<JsonrpcMessageListItem>>())
}
async fn get_message(&self, account_id: u32, msg_id: u32) -> Result<MessageObject> {
@@ -2211,7 +2224,7 @@ impl CommandApi {
&self,
account_id: u32,
message_id: u32,
) -> Result<Option<JSONRPCReactions>> {
) -> Result<Option<JsonrpcReactions>> {
let ctx = self.get_context(account_id).await?;
let reactions = get_msg_reactions(&ctx, MsgId::new(message_id)).await?;
if reactions.is_empty() {
@@ -2312,8 +2325,7 @@ impl CommandApi {
let message = Message::load_from_db(&ctx, MsgId::new(msg_id)).await?;
ensure!(
message.get_viewtype() == Viewtype::Sticker,
"message {} is not a sticker",
msg_id
"message {msg_id} is not a sticker"
);
let account_folder = ctx
.get_dbfile()
@@ -2533,10 +2545,7 @@ impl CommandApi {
.to_u32();
Ok(msg_id)
} else {
Err(anyhow!(
"chat with id {} doesn't have draft message",
chat_id
))
Err(anyhow!("chat with id {chat_id} doesn't have draft message"))
}
}
}

View File

@@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context as _, Result};
use deltachat::calls::{call_state, sdp_has_video, CallState};
use deltachat::context::Context;
@@ -26,7 +26,9 @@ pub struct JsonrpcCallInfo {
impl JsonrpcCallInfo {
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<JsonrpcCallInfo> {
let call_info = context.load_call_by_id(msg_id).await?;
let call_info = context.load_call_by_id(msg_id).await?.with_context(|| {
format!("Attempting to get call state of non-call message {msg_id}")
})?;
let sdp_offer = call_info.place_call_info.clone();
let has_video = sdp_has_video(&sdp_offer).unwrap_or_default();
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;

View File

@@ -19,18 +19,6 @@ pub struct FullChat {
id: u32,
name: String,
/// True if the chat is protected.
///
/// Only verified contacts
/// as determined by [`ContactObject::is_verified`] / `Contact.isVerified`
/// can be added to protected chats.
///
/// Protected chats are created using [`create_group_chat`] / `createGroupChat()`
/// by setting the 'protect' parameter to true.
///
/// [`create_group_chat`]: crate::api::CommandApi::create_group_chat
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
@@ -73,6 +61,13 @@ pub struct FullChat {
is_contact_request: bool,
is_device_chat: bool,
/// Note that this is different from
/// [`ChatListItem::is_self_in_group`](`crate::api::types::chat_list::ChatListItemFetchResult::ChatListItem::is_self_in_group`).
/// This property should only be accessed
/// when [`FullChat::chat_type`] is [`Chattype::Group`].
//
// We could utilize [`Chat::is_self_in_chat`],
// 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
@@ -131,7 +126,6 @@ impl FullChat {
Ok(FullChat {
id: chat_id,
name: chat.name.clone(),
is_protected: chat.is_protected(),
is_encrypted: chat.is_encrypted(context).await?,
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
@@ -172,18 +166,6 @@ pub struct BasicChat {
id: u32,
name: String,
/// True if the chat is protected.
///
/// UI should display a green checkmark
/// in the chat title,
/// in the chat profile title and
/// in the chatlist item
/// if chat protection is enabled.
/// UI should also display a green checkmark
/// in the contact profile
/// if 1:1 chat with this contact exists and is protected.
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
@@ -234,7 +216,6 @@ impl BasicChat {
Ok(BasicChat {
id: chat_id,
name: chat.name.clone(),
is_protected: chat.is_protected(),
is_encrypted: chat.is_encrypted(context).await?,
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
@@ -278,18 +259,18 @@ impl MuteDuration {
#[derive(Clone, Serialize, Deserialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "ChatVisibility")]
pub enum JSONRPCChatVisibility {
pub enum JsonrpcChatVisibility {
Normal,
Archived,
Pinned,
}
impl JSONRPCChatVisibility {
impl JsonrpcChatVisibility {
pub fn into_core_type(self) -> ChatVisibility {
match self {
JSONRPCChatVisibility::Normal => ChatVisibility::Normal,
JSONRPCChatVisibility::Archived => ChatVisibility::Archived,
JSONRPCChatVisibility::Pinned => ChatVisibility::Pinned,
JsonrpcChatVisibility::Normal => ChatVisibility::Normal,
JsonrpcChatVisibility::Archived => ChatVisibility::Archived,
JsonrpcChatVisibility::Pinned => ChatVisibility::Pinned,
}
}
}

View File

@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use deltachat::chat::{Chat, ChatId};
use deltachat::chatlist::get_last_message_for_chat;
use deltachat::constants::*;
use deltachat::contact::{Contact, ContactId};
use deltachat::contact::Contact;
use deltachat::{
chat::{get_chat_contacts, ChatVisibility},
chatlist::Chatlist,
@@ -30,7 +30,6 @@ pub enum ChatListItemFetchResult {
summary_status: u32,
/// showing preview if last chat message is image
summary_preview_image: Option<String>,
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
@@ -127,11 +126,8 @@ pub(crate) async fn get_chat_list_item_by_id(
None => (None, None),
};
let chat_contacts = get_chat_contacts(ctx, chat_id).await?;
let self_in_group = chat_contacts.contains(&ContactId::SELF);
let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single {
let chat_contacts = get_chat_contacts(ctx, chat_id).await?;
let contact = chat_contacts.first();
let was_seen_recently = match contact {
Some(contact) => Contact::get_by_id(ctx, *contact)
@@ -161,13 +157,12 @@ pub(crate) async fn get_chat_list_item_by_id(
summary_text2,
summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum
summary_preview_image,
is_protected: chat.is_protected(),
is_encrypted: chat.is_encrypted(ctx).await?,
is_group: chat.get_type() == Chattype::Group,
fresh_message_counter,
is_self_talk: chat.is_self_talk(),
is_device_talk: chat.is_device_talk(),
is_self_in_group: self_in_group,
is_self_in_group: chat.is_self_in_chat(ctx).await?,
is_sending_location: chat.is_sending_locations(),
is_archived: visibility == ChatVisibility::Archived,
is_pinned: visibility == ChatVisibility::Pinned,

View File

@@ -1,6 +1,6 @@
use anyhow::Result;
use deltachat::color;
use deltachat::context::Context;
use deltachat::key::{DcKey, SignedPublicKey};
use serde::Serialize;
use typescript_type_def::TypeDef;
@@ -130,7 +130,13 @@ pub struct VcardContact {
impl From<deltachat_contact_tools::VcardContact> for VcardContact {
fn from(vc: deltachat_contact_tools::VcardContact) -> Self {
let display_name = vc.display_name().to_string();
let color = color::str_to_color(&vc.addr.to_lowercase());
let is_self = false;
let fpr = vc.key.as_deref().and_then(|k| {
SignedPublicKey::from_base64(k)
.ok()
.map(|k| k.dc_fingerprint())
});
let color = deltachat::contact::get_color(is_self, &vc.addr, &fpr);
Self {
addr: vc.addr,
display_name,

View File

@@ -18,7 +18,7 @@ use typescript_type_def::TypeDef;
use super::color_int_to_hex_string;
use super::contact::ContactObject;
use super::reactions::JSONRPCReactions;
use super::reactions::JsonrpcReactions;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", tag = "kind")]
@@ -102,7 +102,7 @@ pub struct MessageObject {
saved_message_id: Option<u32>,
reactions: Option<JSONRPCReactions>,
reactions: Option<JsonrpcReactions>,
vcard_contact: Option<VcardContact>,
}
@@ -532,7 +532,6 @@ pub struct MessageSearchResult {
chat_color: String,
chat_name: String,
chat_type: u32,
is_chat_protected: bool,
is_chat_contact_request: bool,
is_chat_archived: bool,
message: String,
@@ -572,7 +571,6 @@ impl MessageSearchResult {
chat_color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_profile_image,
is_chat_protected: chat.is_protected(),
is_chat_contact_request: chat.is_contact_request(),
is_chat_archived: chat.get_visibility() == ChatVisibility::Archived,
message: message.get_text(),
@@ -583,7 +581,7 @@ impl MessageSearchResult {
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", rename = "MessageListItem", tag = "kind")]
pub enum JSONRPCMessageListItem {
pub enum JsonrpcMessageListItem {
Message {
msg_id: u32,
},
@@ -596,13 +594,13 @@ pub enum JSONRPCMessageListItem {
},
}
impl From<ChatItem> for JSONRPCMessageListItem {
impl From<ChatItem> for JsonrpcMessageListItem {
fn from(item: ChatItem) -> Self {
match item {
ChatItem::Message { msg_id } => JSONRPCMessageListItem::Message {
ChatItem::Message { msg_id } => JsonrpcMessageListItem::Message {
msg_id: msg_id.to_u32(),
},
ChatItem::DayMarker { timestamp } => JSONRPCMessageListItem::DayMarker { timestamp },
ChatItem::DayMarker { timestamp } => JsonrpcMessageListItem::DayMarker { timestamp },
}
}
}

View File

@@ -1,4 +1,5 @@
use deltachat::qr::Qr;
use serde::Deserialize;
use serde::Serialize;
use typescript_type_def::TypeDef;
@@ -304,3 +305,53 @@ impl From<Qr> for QrObject {
}
}
}
#[derive(Deserialize, TypeDef, schemars::JsonSchema)]
pub enum SecurejoinSource {
/// Because of some problem, it is unknown where the QR code came from.
Unknown,
/// The user opened a link somewhere outside Delta Chat
ExternalLink,
/// The user clicked on a link in a message inside Delta Chat
InternalLink,
/// The user clicked "Paste from Clipboard" in the QR scan activity
Clipboard,
/// The user clicked "Load QR code as image" in the QR scan activity
ImageLoaded,
/// The user scanned a QR code
Scan,
}
#[derive(Deserialize, TypeDef, schemars::JsonSchema)]
pub enum SecurejoinUiPath {
/// The UI path is unknown, or the user didn't open the QR code screen at all.
Unknown,
/// The user directly clicked on the QR icon in the main screen
QrIcon,
/// The user first clicked on the `+` button in the main screen,
/// and then on "New Contact"
NewContact,
}
impl From<SecurejoinSource> for deltachat::SecurejoinSource {
fn from(value: SecurejoinSource) -> Self {
match value {
SecurejoinSource::Unknown => deltachat::SecurejoinSource::Unknown,
SecurejoinSource::ExternalLink => deltachat::SecurejoinSource::ExternalLink,
SecurejoinSource::InternalLink => deltachat::SecurejoinSource::InternalLink,
SecurejoinSource::Clipboard => deltachat::SecurejoinSource::Clipboard,
SecurejoinSource::ImageLoaded => deltachat::SecurejoinSource::ImageLoaded,
SecurejoinSource::Scan => deltachat::SecurejoinSource::Scan,
}
}
}
impl From<SecurejoinUiPath> for deltachat::SecurejoinUiPath {
fn from(value: SecurejoinUiPath) -> Self {
match value {
SecurejoinUiPath::Unknown => deltachat::SecurejoinUiPath::Unknown,
SecurejoinUiPath::QrIcon => deltachat::SecurejoinUiPath::QrIcon,
SecurejoinUiPath::NewContact => deltachat::SecurejoinUiPath::NewContact,
}
}
}

View File

@@ -8,7 +8,7 @@ use typescript_type_def::TypeDef;
/// A single reaction emoji.
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "Reaction", rename_all = "camelCase")]
pub struct JSONRPCReaction {
pub struct JsonrpcReaction {
/// Emoji.
emoji: String,
@@ -22,14 +22,14 @@ pub struct JSONRPCReaction {
/// Structure representing all reactions to a particular message.
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "Reactions", rename_all = "camelCase")]
pub struct JSONRPCReactions {
pub struct JsonrpcReactions {
/// Map from a contact to it's reaction to message.
reactions_by_contact: BTreeMap<u32, Vec<String>>,
/// Unique reactions and their count, sorted in descending order.
reactions: Vec<JSONRPCReaction>,
reactions: Vec<JsonrpcReaction>,
}
impl From<Reactions> for JSONRPCReactions {
impl From<Reactions> for JsonrpcReactions {
fn from(reactions: Reactions) -> Self {
let mut reactions_by_contact: BTreeMap<u32, Vec<String>> = BTreeMap::new();
@@ -56,7 +56,7 @@ impl From<Reactions> for JSONRPCReactions {
false
};
let reaction = JSONRPCReaction {
let reaction = JsonrpcReaction {
emoji,
count,
is_from_self,
@@ -64,7 +64,7 @@ impl From<Reactions> for JSONRPCReactions {
reactions_v.push(reaction)
}
JSONRPCReactions {
JsonrpcReactions {
reactions_by_contact,
reactions: reactions_v,
}

View File

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

View File

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

View File

@@ -6,9 +6,7 @@ use std::str::FromStr;
use std::time::Duration;
use anyhow::{bail, ensure, Result};
use deltachat::chat::{
self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration, ProtectionStatus,
};
use deltachat::chat::{self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration};
use deltachat::chatlist::*;
use deltachat::constants::*;
use deltachat::contact::*;
@@ -347,7 +345,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
createchat <contact-id>\n\
creategroup <name>\n\
createbroadcast <name>\n\
createprotected <name>\n\
addmember <contact-id>\n\
removemember <contact-id>\n\
groupname <name>\n\
@@ -358,6 +355,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
dellocations\n\
getlocations [<contact-id>]\n\
send <text>\n\
send-sync <text>\n\
sendempty\n\
sendimage <file> [<text>]\n\
sendsticker <file> [<text>]\n\
@@ -418,7 +416,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
Ok(setup_code) => {
println!("Setup code for the transferred setup message: {setup_code}",)
}
Err(err) => bail!("Failed to generate setup code: {}", err),
Err(err) => bail!("Failed to generate setup code: {err}"),
},
"get-setupcodebegin" => {
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
@@ -432,7 +430,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
setupcodebegin.unwrap_or_default(),
);
} else {
bail!("{} is no setup message.", msg_id,);
bail!("{msg_id} is no setup message.",);
}
}
"continue-key-transfer" => {
@@ -527,7 +525,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Report written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get connectivity html: {}", err);
bail!("Failed to get connectivity html: {err}");
}
}
}
@@ -562,7 +560,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
for i in (0..cnt).rev() {
let chat = Chat::load_from_db(&context, chatlist.get_chat_id(i)?).await?;
println!(
"{}#{}: {} [{} fresh] {}{}{}{}",
"{}#{}: {} [{} fresh] {}{}{}",
chat_prefix(&chat),
chat.get_id(),
chat.get_name(),
@@ -573,7 +571,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ChatVisibility::Archived => "📦",
ChatVisibility::Pinned => "📌",
},
if chat.is_protected() { "🛡️" } else { "" },
if chat.is_contact_request() {
"🆕"
} else {
@@ -688,7 +685,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
format!("{} member(s)", members.len())
};
println!(
"{}#{}: {} [{}]{}{}{} {}",
"{}#{}: {} [{}]{}{}{}",
chat_prefix(sel_chat),
sel_chat.get_id(),
sel_chat.get_name(),
@@ -706,11 +703,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
},
_ => "".to_string(),
},
if sel_chat.is_protected() {
"🛡️"
} else {
""
},
);
log_msglist(&context, &msglist).await?;
if let Some(draft) = sel_chat.get_id().get_draft(&context).await? {
@@ -739,8 +731,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
}
"creategroup" => {
ensure!(!arg1.is_empty(), "Argument <name> missing.");
let chat_id =
chat::create_group_chat(&context, ProtectionStatus::Unprotected, arg1).await?;
let chat_id = chat::create_group(&context, arg1).await?;
println!("Group#{chat_id} created successfully.");
}
@@ -750,13 +741,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Broadcast#{chat_id} created successfully.");
}
"createprotected" => {
ensure!(!arg1.is_empty(), "Argument <name> missing.");
let chat_id =
chat::create_group_chat(&context, ProtectionStatus::Protected, arg1).await?;
println!("Group#{chat_id} created and protected successfully.");
}
"addmember" => {
ensure!(sel_chat.is_some(), "No chat selected");
ensure!(!arg1.is_empty(), "Argument <contact-id> missing.");
@@ -908,6 +892,23 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), msg).await?;
}
"send-sync" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "No message text given.");
// Send message over a dedicated SMTP connection
// and measure time.
//
// This can be used to benchmark SMTP connection establishment.
let time_start = std::time::Instant::now();
let msg = format!("{arg1} {arg2}");
let mut msg = Message::new_text(msg);
chat::send_msg_sync(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?;
let time_needed = time_start.elapsed();
println!("Sent message in {time_needed:?}.");
}
"sendempty" => {
ensure!(sel_chat.is_some(), "No chat selected.");
chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), "".into()).await?;
@@ -1248,10 +1249,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
}
"providerinfo" => {
ensure!(!arg1.is_empty(), "Argument <addr> missing.");
let proxy_enabled = context
.get_config_bool(config::Config::ProxyEnabled)
.await?;
match provider::get_provider_info(&context, arg1, proxy_enabled).await {
match provider::get_provider_info(arg1) {
Some(info) => {
println!("Information for provider belonging to {arg1}:");
println!("status: {}", info.status as u32);
@@ -1287,7 +1285,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
);
}
"" => (),
_ => bail!("Unknown command: \"{}\" type ? for help.", arg0),
_ => bail!("Unknown command: \"{arg0}\" type ? for help."),
}
Ok(())

View File

@@ -179,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 38] = [
const CHAT_COMMANDS: [&str; 39] = [
"listchats",
"listarchived",
"start-realtime",
@@ -199,6 +199,7 @@ const CHAT_COMMANDS: [&str; 38] = [
"dellocations",
"getlocations",
"send",
"send-sync",
"sendempty",
"sendimage",
"sendsticker",
@@ -466,7 +467,7 @@ async fn handle_cmd(
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {}", err);
bail!("Failed to get QR code svg: {err}");
}
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.18.0"
version = "2.22.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -19,6 +19,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Chat",
"Topic :: Communications :: Email"
]

View File

@@ -300,7 +300,7 @@ class Account:
chats.append(AttrDict(item))
return chats
def create_group(self, name: str, protect: bool = False) -> Chat:
def create_group(self, name: str) -> Chat:
"""Create a new group chat.
After creation,
@@ -317,12 +317,8 @@ class Account:
To check, if a chat is still unpromoted, you can look at the `is_unpromoted` property of a chat
(see `get_full_snapshot()` / `get_basic_snapshot()`).
This may be useful if you want to show some help for just created groups.
:param protect: If set to 1 the function creates group with protection initially enabled.
Only verified members are allowed in these groups
and end-to-end-encryption is always enabled.
"""
return Chat(self, self._rpc.create_group_chat(self.id, name, protect))
return Chat(self, self._rpc.create_group_chat(self.id, name, False))
def create_broadcast(self, name: str) -> Chat:
"""Create a new **broadcast channel**

View File

@@ -83,11 +83,10 @@ class Client:
def configure(self, email: str, password: str, **kwargs) -> None:
"""Configure the client."""
self.account.set_config("addr", email)
self.account.set_config("mail_pw", password)
for key, value in kwargs.items():
self.account.set_config(key, value)
self.account.configure()
params = {"addr": email, "password": password}
self.account.add_or_update_transport(params)
self.logger.debug("Account configured")
def run_forever(self) -> None:

View File

@@ -73,11 +73,11 @@ class ACFactory:
def resetup_account(self, ac: Account) -> Account:
"""Resetup account from scratch, losing the encryption key."""
ac.stop_io()
ac_clone = self.get_unconfigured_account()
for i in ["addr", "mail_pw"]:
ac_clone.set_config(i, ac.get_config(i))
transports = ac.list_transports()
ac.remove()
ac_clone.configure()
ac_clone = self.get_unconfigured_account()
for transport in transports:
ac_clone.add_or_update_transport(transport)
return ac_clone
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:

View File

@@ -9,6 +9,7 @@ def test_calls(acfactory) -> None:
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info)
assert outgoing_call_message.get_call_info().state.kind == "Alerting"
@@ -67,6 +68,7 @@ a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call(place_call_info)
@@ -84,3 +86,24 @@ def test_ice_servers(acfactory) -> None:
ice_servers = alice.ice_servers()
assert len(ice_servers) == 1
def test_no_contact_request_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer")
alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive
# without the call ringing.
while True:
event = bob.wait_for_event()
# There should be no incoming call notification.
assert event.kind != EventType.INCOMING_CALL
if event.kind == EventType.INCOMING_MSG:
msg = bob.get_message_by_id(event.msg_id)
assert msg.get_snapshot().text == "Hello!"
break

View File

@@ -18,9 +18,7 @@ def test_autocrypt_setup_message_key_transfer(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.set_config("addr", alice1.get_config("addr"))
alice2.set_config("mail_pw", alice1.get_config("mail_pw"))
alice2.configure()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
setup_code = alice1.initiate_autocrypt_key_transfer()
@@ -37,9 +35,7 @@ def test_ac_setup_message_twice(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.set_config("addr", alice1.get_config("addr"))
alice2.set_config("mail_pw", alice1.get_config("mail_pw"))
alice2.configure()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
# Send the first Autocrypt Setup Message and ignore it.

View File

@@ -58,8 +58,7 @@ def test_qr_setup_contact_svg(acfactory) -> None:
assert "Alice" in svg
@pytest.mark.parametrize("protect", [True, False])
def test_qr_securejoin(acfactory, protect):
def test_qr_securejoin(acfactory):
alice, bob, fiona = acfactory.get_online_accounts(3)
# Setup second device for Alice
@@ -67,8 +66,7 @@ def test_qr_securejoin(acfactory, protect):
alice2 = alice.clone()
logging.info("Alice creates a group")
alice_chat = alice.create_group("Group", protect=protect)
assert alice_chat.get_basic_snapshot().is_protected == protect
alice_chat = alice.create_group("Group")
logging.info("Bob joins the group")
qr_code = alice_chat.get_qr_code()
@@ -89,7 +87,6 @@ def test_qr_securejoin(acfactory, protect):
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
assert snapshot.chat.get_basic_snapshot().is_protected == protect
# Test that Bob verified Alice's profile.
bob_contact_alice = bob.create_contact(alice)
@@ -125,8 +122,8 @@ def test_qr_securejoin_contact_request(acfactory) -> None:
bob_chat_alice = snapshot.chat
assert bob_chat_alice.get_basic_snapshot().is_contact_request
alice_chat = alice.create_group("Verified group", protect=True)
logging.info("Bob joins verified group")
alice_chat = alice.create_group("Group")
logging.info("Bob joins the group")
qr_code = alice_chat.get_qr_code()
bob.secure_join(qr_code)
while True:
@@ -150,8 +147,8 @@ def test_qr_readreceipt(acfactory) -> None:
for joiner in [bob, charlie]:
joiner.wait_for_securejoin_joiner_success()
logging.info("Alice creates a verified group")
group = alice.create_group("Group", protect=True)
logging.info("Alice creates a group")
group = alice.create_group("Group")
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_contact_charlie = alice.create_contact(charlie, "Charlie")
@@ -216,11 +213,10 @@ def test_verified_group_member_added_recovery(acfactory) -> None:
"""Tests verified group recovery by reverifying then removing and adding a member back."""
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
logging.info("ac1 creates verified group")
chat = ac1.create_group("Verified group", protect=True)
assert chat.get_basic_snapshot().is_protected
logging.info("ac1 creates a group")
chat = ac1.create_group("Group")
logging.info("ac2 joins verified group")
logging.info("ac2 joins the group")
qr_code = chat.get_qr_code()
ac2.secure_join(qr_code)
ac2.wait_for_securejoin_joiner_success()
@@ -302,8 +298,8 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
# we first create a fully joined verified group, and then start
# joining a second time but interrupt it, to create pending bob state
logging.info("ac1: create verified group that ac2 fully joins")
ch1 = ac1.create_group("Group", protect=True)
logging.info("ac1: create a group that ac2 fully joins")
ch1 = ac1.create_group("Group")
qr_code = ch1.get_qr_code()
ac2.secure_join(qr_code)
ac1.wait_for_securejoin_inviter_success()
@@ -313,7 +309,6 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
while 1:
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
if snapshot.text == "ac1 says hello":
assert snapshot.chat.get_basic_snapshot().is_protected
break
logging.info("ac1: let ac2 join again but shutoff ac1 in the middle of securejoin")
@@ -327,7 +322,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
assert ac2.create_contact(ac3).get_snapshot().is_verified
logging.info("ac3: create a verified group VG with ac2")
vg = ac3.create_group("ac3-created", protect=True)
vg = ac3.create_group("ac3-created")
vg.add_contact(ac3.create_contact(ac2))
# ensure ac2 receives message in VG
@@ -335,7 +330,6 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
while 1:
msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
if msg.text == "hello":
assert msg.chat.get_basic_snapshot().is_protected
break
logging.info("ac3: create a join-code for group VG and let ac4 join, check that ac2 got it")
@@ -359,7 +353,7 @@ def test_qr_new_group_unblocked(acfactory):
"""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_chat = ac1.create_group("Group for joining", protect=True)
ac1_chat = ac1.create_group("Group for joining")
qr_code = ac1_chat.get_qr_code()
ac2.secure_join(qr_code)
@@ -384,8 +378,7 @@ def test_aeap_flow_verified(acfactory):
addr, password = acfactory.get_credentials()
logging.info("ac1: create verified-group QR, ac2 scans and joins")
chat = ac1.create_group("hello", protect=True)
assert chat.get_basic_snapshot().is_protected
chat = ac1.create_group("hello")
qr_code = chat.get_qr_code()
logging.info("ac2: start QR-code based join-group protocol")
ac2.secure_join(qr_code)
@@ -439,7 +432,6 @@ def test_gossip_verification(acfactory) -> None:
logging.info("Bob creates an Autocrypt group")
bob_group_chat = bob.create_group("Autocrypt Group")
assert not bob_group_chat.get_basic_snapshot().is_protected
bob_group_chat.add_contact(bob_contact_alice)
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Autocrypt group")
@@ -448,13 +440,12 @@ def test_gossip_verification(acfactory) -> None:
assert snapshot.text == "Hello Autocrypt group"
assert snapshot.show_padlock
# Autocrypt group does not propagate verification.
# Group propagates verification using Autocrypt-Gossip header.
carol_contact_alice_snapshot = carol_contact_alice.get_snapshot()
assert not carol_contact_alice_snapshot.is_verified
assert carol_contact_alice_snapshot.is_verified
logging.info("Bob creates a Securejoin group")
bob_group_chat = bob.create_group("Securejoin Group", protect=True)
assert bob_group_chat.get_basic_snapshot().is_protected
bob_group_chat = bob.create_group("Securejoin Group")
bob_group_chat.add_contact(bob_contact_alice)
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Securejoin group")
@@ -477,7 +468,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
# ac3 creates protected group with ac1.
ac3_chat = ac3.create_group("Verified group", protect=True)
ac3_chat = ac3.create_group("Group")
# ac1 joins ac3 group.
ac3_qr_code = ac3_chat.get_qr_code()
@@ -525,7 +516,6 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
assert snapshot.is_info
ac2_chat = snapshot.chat
assert ac2_chat.get_basic_snapshot().is_protected
assert len(ac2_chat.get_contacts()) == 3
# ac1 is still "not verified" for ac2 due to inconsistent state.
@@ -535,9 +525,8 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
def test_withdraw_securejoin_qr(acfactory):
alice, bob = acfactory.get_online_accounts(2)
logging.info("Alice creates a verified group")
alice_chat = alice.create_group("Verified group", protect=True)
assert alice_chat.get_basic_snapshot().is_protected
logging.info("Alice creates a group")
alice_chat = alice.create_group("Group")
logging.info("Bob joins verified group")
qr_code = alice_chat.get_qr_code()
@@ -548,7 +537,6 @@ def test_withdraw_securejoin_qr(acfactory):
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
assert snapshot.chat.get_basic_snapshot().is_protected
bob_chat.leave()
snapshot = alice.get_message_by_id(alice.wait_for_msgs_changed_event().msg_id).get_snapshot()

View File

@@ -570,8 +570,11 @@ def test_provider_info(rpc) -> None:
assert provider_info is None
# Test MX record resolution.
# This previously resulted in Gmail provider
# because MX record pointed to google.com domain,
# but MX record resolution has been removed.
provider_info = rpc.get_provider_info(account_id, "github.com")
assert provider_info["id"] == "gmail"
assert provider_info is None
# Disable MX record resolution.
rpc.set_config(account_id, "proxy_enabled", "1")

View File

@@ -1,8 +1,11 @@
def test_vcard(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice, bob, fiona = acfactory.get_online_accounts(3)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_contact_charlie = alice.create_contact("charlie@example.org", "Charlie")
alice_contact_charlie_snapshot = alice_contact_charlie.get_snapshot()
alice_contact_fiona = alice.create_contact(fiona, "Fiona")
alice_contact_fiona_snapshot = alice_contact_fiona.get_snapshot()
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_contact(alice_contact_charlie)
@@ -12,3 +15,12 @@ def test_vcard(acfactory) -> None:
snapshot = message.get_snapshot()
assert snapshot.vcard_contact
assert snapshot.vcard_contact.addr == "charlie@example.org"
assert snapshot.vcard_contact.color == alice_contact_charlie_snapshot.color
alice_chat_bob.send_contact(alice_contact_fiona)
event = bob.wait_for_incoming_msg_event()
message = bob.get_message_by_id(event.msg_id)
snapshot = message.get_snapshot()
assert snapshot.vcard_contact
assert snapshot.vcard_contact.key
assert snapshot.vcard_contact.color == alice_contact_fiona_snapshot.color

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.18.0"
version = "2.22.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.18.0"
"version": "2.22.0"
}

View File

@@ -41,22 +41,22 @@ async fn main_impl() -> Result<()> {
if let Some(first_arg) = args.next() {
if first_arg.to_str() == Some("--version") {
if let Some(arg) = args.next() {
return Err(anyhow!("Unrecognized argument {:?}", arg));
return Err(anyhow!("Unrecognized argument {arg:?}"));
}
eprintln!("{}", &*DC_VERSION_STR);
return Ok(());
} else if first_arg.to_str() == Some("--openrpc") {
if let Some(arg) = args.next() {
return Err(anyhow!("Unrecognized argument {:?}", arg));
return Err(anyhow!("Unrecognized argument {arg:?}"));
}
println!("{}", CommandApi::openrpc_specification()?);
return Ok(());
} else {
return Err(anyhow!("Unrecognized option {:?}", first_arg));
return Err(anyhow!("Unrecognized option {first_arg:?}"));
}
}
if let Some(arg) = args.next() {
return Err(anyhow!("Unrecognized argument {:?}", arg));
return Err(anyhow!("Unrecognized argument {arg:?}"));
}
// Install signal handlers early so that the shutdown is graceful starting from here.

View File

@@ -20,6 +20,11 @@ impl SystemTimeTools {
pub fn shift(duration: Duration) {
*SYSTEM_TIME_SHIFT.write().unwrap() += duration;
}
/// Simulates the system clock being rewound by `duration`.
pub fn shift_back(duration: Duration) {
*SYSTEM_TIME_SHIFT.write().unwrap() -= duration;
}
}
#[cfg(test)]

View File

@@ -36,8 +36,6 @@ skip = [
{ name = "rand_chacha", version = "0.3.1" },
{ name = "rand_core", version = "0.6.4" },
{ name = "rand", version = "0.8.5" },
{ name = "redox_syscall", version = "0.3.5" },
{ name = "redox_syscall", version = "0.4.1" },
{ name = "rustix", version = "0.38.44" },
{ name = "serdect", version = "0.2.0" },
{ name = "spin", version = "0.9.8" },

View File

@@ -98,9 +98,6 @@
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
];
buildInputs = pkgs.lib.optionals isDarwin [
pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
};
@@ -240,6 +237,9 @@
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS = "-Clink-args=-L${pkgsCross.libiconv}/lib";
CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS = "-Clink-args=-L${pkgsCross.libiconv}/lib";
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${pkgsCross.stdenv.cc}/bin/${pkgsCross.stdenv.cc.targetPrefix}cc";
CARGO_BUILD_RUSTFLAGS = [
@@ -483,12 +483,6 @@
pkgs.rustPlatform.cargoSetupHook
pkgs.cargo
];
buildInputs = pkgs.lib.optionals isDarwin [
pkgs.darwin.apple_sdk.frameworks.CoreFoundation
pkgs.darwin.apple_sdk.frameworks.Security
pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
pkgs.libiconv
];
postInstall = ''
substituteInPlace $out/include/deltachat.h \
@@ -587,6 +581,7 @@
(python3.withPackages (pypkgs: with pypkgs; [
tox
]))
nodejs
];
};
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.18.0"
version = "2.22.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
requires-python = ">=3.8"

View File

@@ -404,18 +404,16 @@ class Account:
self,
name: str,
contacts: Optional[List[Contact]] = None,
verified: bool = False,
) -> Chat:
"""create a new group chat object.
Chats are unpromoted until the first message is sent.
:param contacts: list of contacts to add
:param verified: if true only verified contacts can be added.
:returns: a :class:`deltachat.chat.Chat` object.
"""
bytes_name = name.encode("utf8")
chat_id = lib.dc_create_group_chat(self._dc_context, int(verified), bytes_name)
chat_id = lib.dc_create_group_chat(self._dc_context, 0, bytes_name)
chat = Chat(self, chat_id)
if contacts is not None:
for contact in contacts:

View File

@@ -142,13 +142,6 @@ class Chat:
"""
return bool(lib.dc_chat_can_send(self._dc_chat))
def is_protected(self) -> bool:
"""return True if this chat is a protected chat.
:returns: True if chat is protected, False otherwise.
"""
return bool(lib.dc_chat_is_protected(self._dc_chat))
def get_name(self) -> Optional[str]:
"""return name of this chat.

View File

@@ -604,20 +604,6 @@ class ACFactory:
ac2.create_chat(ac1)
return ac1.create_chat(ac2)
def get_protected_chat(self, ac1: Account, ac2: Account):
chat = ac1.create_group_chat("Protected Group", verified=True)
qr = chat.get_join_qr()
ac2.qr_join_chat(qr)
ac2._evtracker.wait_securejoin_joiner_progress(1000)
ev = ac2._evtracker.get_matching("DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
assert msg is not None
assert msg.text == "Messages are end-to-end encrypted."
msg = ac2._evtracker.wait_next_incoming_message()
assert msg is not None
assert "Member Me " in msg.text and " added by " in msg.text
return chat
def introduce_each_other(self, accounts, sending=True):
to_wait = []
for i, acc in enumerate(accounts):

View File

@@ -118,8 +118,7 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
ac1_addr = ac1.get_self_contact().addr
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
chat1 = ac1.create_group_chat("hello", verified=True)
assert chat1.is_protected()
chat1 = ac1.create_group_chat("hello")
qr = chat1.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
chat2 = ac2.qr_join_chat(qr)
@@ -142,7 +141,6 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
lp.sec("ac2: read message and check that it's a verified chat")
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
assert msg.chat.is_protected()
assert msg.is_encrypted()
lp.sec("ac2: Check that ac2 verified ac1")
@@ -173,8 +171,10 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
lp.sec("ac2: Check that ac1 verified ac3 for ac2")
ac2_ac1_contact = ac2.get_contacts()[0]
assert ac2.get_self_contact().get_verifier(ac2_ac1_contact).id == dc.const.DC_CONTACT_ID_SELF
ac2_ac3_contact = ac2.get_contacts()[1]
assert ac2.get_self_contact().get_verifier(ac2_ac3_contact).addr == ac1_addr
for ac2_contact in chat2.get_contacts():
if ac2_contact == ac2_ac1_contact or ac2_contact.id == dc.const.DC_CONTACT_ID_SELF:
continue
assert ac2.get_self_contact().get_verifier(ac2_contact).addr == ac1_addr
lp.sec("ac2: send message and let ac3 read it")
chat2.send_text("hi")
@@ -266,8 +266,7 @@ def test_see_new_verified_member_after_going_online(acfactory, tmp_path, lp):
ac1_offl.stop_io()
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
chat = ac1.create_group_chat("hello", verified=True)
assert chat.is_protected()
chat = ac1.create_group_chat("hello")
qr = chat.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
chat2 = ac2.qr_join_chat(qr)
@@ -321,8 +320,7 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp
ac1.set_avatar(avatar_path)
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
chat = ac1.create_group_chat("hello", verified=True)
assert chat.is_protected()
chat = ac1.create_group_chat("hello")
qr = chat.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
ac2.qr_join_chat(qr)
@@ -336,7 +334,6 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp
assert msg_in.is_system_message()
assert contact.addr == ac1.get_config("addr")
chat2 = msg_in.chat
assert chat2.is_protected()
assert chat2.get_messages()[0].text == "Messages are end-to-end encrypted."
assert open(contact.get_profile_image(), "rb").read() == open(avatar_path, "rb").read()
@@ -376,8 +373,7 @@ def test_verified_group_vs_delete_server_after(acfactory, tmp_path, lp):
ac2_offl.stop_io()
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
chat1 = ac1.create_group_chat("hello", verified=True)
assert chat1.is_protected()
chat1 = ac1.create_group_chat("hello")
qr = chat1.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
chat2 = ac2.qr_join_chat(qr)
@@ -402,28 +398,17 @@ def test_verified_group_vs_delete_server_after(acfactory, tmp_path, lp):
assert ac2_offl_ac1_contact.addr == ac1.get_config("addr")
assert not ac2_offl_ac1_contact.is_verified()
chat2_offl = msg_in.chat
assert not chat2_offl.is_protected()
lp.sec("ac2: sending message re-gossiping Autocrypt keys")
chat2.send_text("hi2")
lp.sec("ac2_offl: receiving message")
ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg_in = ac2_offl.get_message_by_id(ev.data2)
assert msg_in.is_system_message()
assert msg_in.text == "Messages are end-to-end encrypted."
# We need to consume one event that has data2=0
ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
assert ev.data2 == 0
ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg_in = ac2_offl.get_message_by_id(ev.data2)
assert not msg_in.is_system_message()
assert msg_in.text == "hi2"
assert msg_in.chat == chat2_offl
assert msg_in.get_sender_contact().addr == ac2.get_config("addr")
assert msg_in.chat.is_protected()
assert ac2_offl_ac1_contact.is_verified()

View File

@@ -308,7 +308,7 @@ def test_move_works(acfactory):
def test_move_avoids_loop(acfactory):
"""Test that the message is only moved once.
"""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.
@@ -319,6 +319,14 @@ def test_move_avoids_loop(acfactory):
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")
@@ -326,20 +334,28 @@ def test_move_avoids_loop(acfactory):
ac2_msg1 = ac2._evtracker.wait_next_incoming_message()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX again.
# 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")
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"
# Check that Message 1 is still in the INBOX folder
# 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()) == 1
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):
@@ -450,14 +466,19 @@ def test_resend_message(acfactory, lp):
lp.sec("ac2: receive message")
msg_in = ac2._evtracker.wait_next_incoming_message()
assert msg_in.text == "message"
chat2 = msg_in.chat
chat2_msg_cnt = len(chat2.get_messages())
lp.sec("ac1: resend message")
ac1.resend_messages([msg_in])
lp.sec("ac2: check that message is deleted")
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
lp.sec("ac1: send another message")
chat1.send_text("another message")
lp.sec("ac2: receive another message")
msg_in = ac2._evtracker.wait_next_incoming_message()
assert msg_in.text == "another message"
chat2 = msg_in.chat
chat2_msg_cnt = len(chat2.get_messages())
assert len(chat2.get_messages()) == chat2_msg_cnt
@@ -1204,7 +1225,7 @@ def test_import_export_online_all(acfactory, tmp_path, data, lp):
def test_qr_email_capitalization(acfactory, lp):
"""Regression test for a bug
that resulted in failure to propagate verification via gossip in a verified group
that resulted in failure to propagate verification
when the database already contained the contact with a different email address capitalization.
"""
@@ -1215,17 +1236,17 @@ def test_qr_email_capitalization(acfactory, lp):
lp.sec(f"ac1 creates a contact for ac2 ({ac2_addr_uppercase})")
ac1.create_contact(ac2_addr_uppercase)
lp.sec("ac3 creates a verified group with a QR code")
chat = ac3.create_group_chat("hello", verified=True)
lp.sec("ac3 creates a group with a QR code")
chat = ac3.create_group_chat("hello")
qr = chat.get_join_qr()
lp.sec("ac1 joins a verified group via a QR code")
lp.sec("ac1 joins a group via a QR code")
ac1_chat = ac1.qr_join_chat(qr)
msg = ac1._evtracker.wait_next_incoming_message()
assert msg.text == "Member Me added by {}.".format(ac3.get_config("addr"))
assert len(ac1_chat.get_contacts()) == 2
lp.sec("ac2 joins a verified group via a QR code")
lp.sec("ac2 joins a group via a QR code")
ac2.qr_join_chat(qr)
ac1._evtracker.wait_next_incoming_message()
@@ -1757,10 +1778,10 @@ def test_group_quote(acfactory, lp):
"xyz",
), # Test that emails aren't found in a random folder
(
"Spam",
"xyz",
True,
"DeltaChat",
), # ...emails are moved from the spam folder to "DeltaChat"
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,

View File

@@ -271,10 +271,9 @@ class TestOfflineChat:
chat.set_name("Homework")
assert chat.get_messages()[-1].text == "abc homework xyz Homework"
@pytest.mark.parametrize("verified", [True, False])
def test_group_chat_qr(self, acfactory, ac1, verified):
def test_group_chat_qr(self, acfactory, ac1):
ac2 = acfactory.get_pseudo_configured_account()
chat = ac1.create_group_chat(name="title1", verified=verified)
chat = ac1.create_group_chat(name="title1")
assert chat.is_group()
qr = chat.get_join_qr()
assert ac2.check_qr(qr).is_ask_verifygroup

View File

@@ -1 +1 @@
2025-10-08
2025-10-17

20
spec.md
View File

@@ -1,6 +1,6 @@
# Chatmail Specification
Version: 0.36.0
Version: 0.37.0
Status: In-progress
Format: [Semantic Line Breaks](https://sembr.org/)
@@ -582,6 +582,24 @@ and e.g. simply search for the line starting with `EMAIL`
in order to get the email address.
# Verifications
Keys obtained using [SecureJoin](https://securejoin.readthedocs.io) protocol
and corresponding contacts
are considered "verified".
As an extension to `Autocrypt-Gossip` header,
chatmail clients can add `_verified=1` attribute
(underscore marks the attribute as non-critical)
to indicate that they have the gossiped key
and the corresponding contact marked as verified.
When receiving such `Autocrypt-Gossip` header
in a message signed by a verified key,
chatmail clients mark the gossiped key
as indirectly verified.
# Transitioning to a new e-mail address (AEAP)
When receiving a message:

View File

@@ -78,7 +78,7 @@ impl Accounts {
ensure!(dir.exists(), "directory does not exist");
let config_file = dir.join(CONFIG_NAME);
ensure!(config_file.exists(), "{:?} does not exist", config_file);
ensure!(config_file.exists(), "{config_file:?} does not exist");
let config = Config::from_file(config_file, writable).await?;
let events = Events::new();
@@ -724,8 +724,7 @@ impl Config {
{
ensure!(
self.inner.accounts.iter().any(|e| e.id == id),
"invalid account id: {}",
id
"invalid account id: {id}"
);
self.inner.selected_account = id;

View File

@@ -35,7 +35,7 @@ impl FromStr for EncryptPreference {
match s {
"mutual" => Ok(EncryptPreference::Mutual),
"nopreference" => Ok(EncryptPreference::NoPreference),
_ => bail!("Cannot parse encryption preference {}", s),
_ => bail!("Cannot parse encryption preference {s}"),
}
}
}

View File

@@ -32,7 +32,7 @@ pub(crate) async fn handle_authres(
let from_domain = match EmailAddress::new(from) {
Ok(email) => email.domain,
Err(e) => {
return Err(anyhow::format_err!("invalid email {}: {:#}", from, e));
return Err(anyhow::format_err!("invalid email {from}: {e:#}"));
}
};

View File

@@ -170,7 +170,7 @@ impl<'a> BlobObject<'a> {
false => name,
};
if !BlobObject::is_acceptible_blob_name(name) {
return Err(format_err!("not an acceptable blob name: {}", name));
return Err(format_err!("not an acceptable blob name: {name}"));
}
Ok(BlobObject {
blobdir: context.get_blobdir(),
@@ -458,8 +458,7 @@ impl<'a> BlobObject<'a> {
{
if img_wh < 20 {
return Err(format_err!(
"Failed to scale image to below {}B.",
max_bytes,
"Failed to scale image to below {max_bytes}B.",
));
}
@@ -538,7 +537,11 @@ fn file_hash(src: &Path) -> Result<blake3::Hash> {
fn image_metadata(file: &std::fs::File) -> Result<(u64, Option<exif::Exif>)> {
let len = file.metadata()?.len();
let mut bufreader = std::io::BufReader::new(file);
let exif = exif::Reader::new().read_from_container(&mut bufreader).ok();
let exif = exif::Reader::new()
.continue_on_error(true)
.read_from_container(&mut bufreader)
.or_else(|e| e.distill_partial_result(|_errors| {}))
.ok();
Ok((len, exif))
}

View File

@@ -334,6 +334,28 @@ async fn test_recode_image_2() {
assert_correct_rotation(&img_rotated);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_bad_exif() {
// `exiftool` reports for this file "Bad offset for IFD0 XResolution", still Exif must be
// detected and removed.
let bytes = include_bytes!("../../test-data/image/1000x1000-bad-exif.jpg");
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "0",
bytes,
extension: "jpg",
has_exif: true,
original_width: 1000,
original_height: 1000,
compressed_width: 1000,
compressed_height: 1000,
..Default::default()
}
.test()
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_balanced_png() {
let bytes = include_bytes!("../../test-data/image/screenshot.png");
@@ -418,7 +440,7 @@ async fn test_recode_image_balanced_png() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_with_exif() {
let bytes = include_bytes!("../../test-data/image/logo.png");
let bytes = include_bytes!("../../test-data/image/logo-exif.png");
SendImageCheckMediaquality {
viewtype: Viewtype::Sticker,
bytes,

View File

@@ -2,8 +2,9 @@
//!
//! Internally, calls are bound a user-visible message initializing the call.
//! This means, the "Call ID" is a "Message ID" - similar to Webxdc IDs.
use crate::chat::ChatIdBlocked;
use crate::chat::{Chat, ChatId, send_msg};
use crate::constants::Chattype;
use crate::constants::{Blocked, Chattype};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
@@ -96,7 +97,7 @@ impl CallInfo {
let duration = match minutes {
0 => "<1 minute".to_string(),
1 => "1 minute".to_string(),
n => format!("{} minutes", n),
n => format!("{n} minutes"),
};
if self.is_incoming() {
@@ -213,7 +214,9 @@ impl Context {
call_id: MsgId,
accept_call_info: String,
) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?;
let mut call: CallInfo = self.load_call_by_id(call_id).await?.with_context(|| {
format!("accept_incoming_call is called with {call_id} which does not refer to a call")
})?;
ensure!(call.is_incoming());
if call.is_accepted() || call.is_ended() {
info!(self, "Call already accepted/ended");
@@ -248,7 +251,9 @@ impl Context {
/// Cancel, decline or hangup an incoming or outgoing call.
pub async fn end_call(&self, call_id: MsgId) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?;
let mut call: CallInfo = self.load_call_by_id(call_id).await?.with_context(|| {
format!("end_call is called with {call_id} which does not refer to a call")
})?;
if call.is_ended() {
info!(self, "Call already ended");
return Ok(());
@@ -291,7 +296,13 @@ impl Context {
call_id: MsgId,
) -> Result<()> {
sleep(Duration::from_secs(wait)).await;
let mut call = context.load_call_by_id(call_id).await?;
let Some(mut call) = context.load_call_by_id(call_id).await? else {
warn!(
context,
"emit_end_call_if_unaccepted is called with {call_id} which does not refer to a call."
);
return Ok(());
};
if !call.is_accepted() && !call.is_ended() {
if call.is_incoming() {
call.mark_as_canceled(&context).await?;
@@ -316,7 +327,10 @@ impl Context {
from_id: ContactId,
) -> Result<()> {
if mime_message.is_call() {
let call = self.load_call_by_id(call_id).await?;
let Some(call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_incoming() {
if call.is_stale() {
@@ -332,12 +346,27 @@ impl Context {
false
}
};
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(),
has_video,
});
if let Some(chat_id_blocked) =
ChatIdBlocked::lookup_by_contact(self, from_id).await?
{
match chat_id_blocked.blocked {
Blocked::Not => {
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(),
has_video,
});
}
Blocked::Yes | Blocked::Request => {
// Do not notify about incoming calls
// from contact requests and blocked contacts.
//
// User can still access the call and accept it
// via the chat in case of contact requests.
}
}
}
let wait = call.remaining_ring_seconds();
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
@@ -352,7 +381,11 @@ impl Context {
} else {
match mime_message.is_system_message {
SystemMessage::CallAccepted => {
let mut call = self.load_call_by_id(call_id).await?;
let Some(mut call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_ended() || call.is_accepted() {
info!(self, "CallAccepted received for accepted/ended call");
return Ok(());
@@ -377,7 +410,11 @@ impl Context {
}
}
SystemMessage::CallEnded => {
let mut call = self.load_call_by_id(call_id).await?;
let Some(mut call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_ended() {
// may happen eg. if a a message is missed
info!(self, "CallEnded received for ended call");
@@ -421,15 +458,26 @@ impl Context {
}
/// Loads information about the call given its ID.
pub async fn load_call_by_id(&self, call_id: MsgId) -> Result<CallInfo> {
///
/// If the message referred to by ID is
/// not a call message, returns `None`.
pub async fn load_call_by_id(&self, call_id: MsgId) -> Result<Option<CallInfo>> {
let call = Message::load_from_db(self, call_id).await?;
self.load_call_by_message(call)
Ok(self.load_call_by_message(call))
}
fn load_call_by_message(&self, call: Message) -> Result<CallInfo> {
ensure!(call.viewtype == Viewtype::Call);
// Loads information about the call given the `Message`.
//
// If the `Message` is not a call message, returns `None`
fn load_call_by_message(&self, call: Message) -> Option<CallInfo> {
if call.viewtype != Viewtype::Call {
// This can happen e.g. if a "call accepted"
// or "call ended" message is received
// with `In-Reply-To` referring to non-call message.
return None;
}
Ok(CallInfo {
Some(CallInfo {
place_call_info: call
.param
.get(Param::WebrtcRoom)
@@ -497,8 +545,13 @@ pub enum CallState {
}
/// Returns call state given the message ID.
///
/// Returns an error if the message is not a call message.
pub async fn call_state(context: &Context, msg_id: MsgId) -> Result<CallState> {
let call = context.load_call_by_id(msg_id).await?;
let call = context
.load_call_by_id(msg_id)
.await?
.with_context(|| format!("{msg_id} is not a call message"))?;
let state = if call.is_incoming() {
if call.is_accepted() {
if call.is_ended() {

View File

@@ -1,6 +1,8 @@
use super::*;
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::test_utils::{TestContext, TestContextManager};
struct CallSetup {
@@ -43,6 +45,12 @@ async fn setup_call() -> Result<CallSetup> {
// Alice creates a chat with Bob and places an outgoing call there.
// Alice's other device sees the same message as an outgoing call.
let alice_chat = alice.create_chat(&bob).await;
// Create chat on Bob's side
// so incoming call causes a notification.
bob.create_chat(&alice).await;
bob2.create_chat(&alice).await;
let test_msg_id = alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string())
.await?;
@@ -53,7 +61,10 @@ async fn setup_call() -> Result<CallSetup> {
for (t, m) in [(&alice, &alice_call), (&alice2, &alice2_call)] {
assert!(!m.is_info());
assert_eq!(m.viewtype, Viewtype::Call);
let info = t.load_call_by_id(m.id).await?;
let info = t
.load_call_by_id(m.id)
.await?
.expect("m should be a call message");
assert!(!info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
@@ -71,7 +82,10 @@ async fn setup_call() -> Result<CallSetup> {
t.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCall { .. }))
.await;
let info = t.load_call_by_id(m.id).await?;
let info = t
.load_call_by_id(m.id)
.await?
.expect("IncomingCall event should refer to a call message");
assert!(info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
@@ -111,7 +125,10 @@ async fn accept_call() -> Result<CallSetup> {
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob.load_call_by_id(bob_call.id).await?;
let info = bob
.load_call_by_id(bob_call.id)
.await?
.expect("bob_call should be a call message");
assert!(info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active);
@@ -121,7 +138,10 @@ async fn accept_call() -> Result<CallSetup> {
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let info = bob2.load_call_by_id(bob2_call.id).await?;
let info = bob2
.load_call_by_id(bob2_call.id)
.await?
.expect("bob2_call should be a call message");
assert!(info.is_accepted());
assert_eq!(call_state(&bob2, bob2_call.id).await?, CallState::Active);
@@ -140,7 +160,10 @@ async fn accept_call() -> Result<CallSetup> {
accept_call_info: ACCEPT_INFO.to_string()
}
);
let info = alice.load_call_by_id(alice_call.id).await?;
let info = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
assert!(info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active);
@@ -460,14 +483,20 @@ async fn test_mark_calls() -> Result<()> {
alice, alice_call, ..
} = setup_call().await?;
let mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?;
let mut call_info: CallInfo = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
assert!(!call_info.is_accepted());
assert!(!call_info.is_ended());
call_info.mark_as_accepted(&alice).await?;
assert!(call_info.is_accepted());
assert!(!call_info.is_ended());
let mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?;
let mut call_info: CallInfo = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
assert!(call_info.is_accepted());
assert!(!call_info.is_ended());
@@ -484,7 +513,10 @@ async fn test_update_call_text() -> Result<()> {
alice, alice_call, ..
} = setup_call().await?;
let call_info = alice.load_call_by_id(alice_call.id).await?;
let call_info = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
call_info.update_text(&alice, "foo bar").await?;
let alice_call = Message::load_from_db(&alice, alice_call.id).await?;
@@ -529,3 +561,114 @@ async fn test_forward_call() -> Result<()> {
Ok(())
}
/// Tests that "end call" message referring
/// to a text message does not make receive_imf fail.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_end_text_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let received1 = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <first@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
Chat-Version: 1.0\n\
\n\
Hello\n",
false,
)
.await?
.unwrap();
assert_eq!(received1.msg_ids.len(), 1);
let msg = Message::load_from_db(alice, received1.msg_ids[0])
.await
.unwrap();
assert_eq!(msg.viewtype, Viewtype::Text);
// Receiving "Call ended" message that refers
// to the text message does not result in an error.
let received2 = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <second@example.net>\n\
Date: Sun, 22 Mar 2020 23:37:57 +0000\n\
In-Reply-To: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call-ended\n\
\n\
Call ended\n",
false,
)
.await?
.unwrap();
assert_eq!(received2.msg_ids.len(), 1);
assert_eq!(received2.chat_id, DC_CHAT_ID_TRASH);
Ok(())
}
/// Tests that partially downloaded "call ended"
/// messages are not processed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_partial_calls() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let seen = false;
// The messages in the test
// have no `Date` on purpose,
// so they are treated as new.
let received_call = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call\n\
Chat-Webrtc-Room: YWFhYWFhYWFhCg==\n\
\n\
Hello, this is a call\n",
seen,
)
.await?
.unwrap();
assert_eq!(received_call.msg_ids.len(), 1);
let call_msg = Message::load_from_db(alice, received_call.msg_ids[0])
.await
.unwrap();
assert_eq!(call_msg.viewtype, Viewtype::Call);
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
let imf_raw = b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <second@example.net>\n\
In-Reply-To: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call-ended\n\
\n\
Call ended\n";
receive_imf_from_inbox(
alice,
"second@example.net",
imf_raw,
seen,
Some(imf_raw.len().try_into().unwrap()),
)
.await?;
// The call is still not ended.
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
// Fully downloading the message ends the call.
receive_imf_from_inbox(alice, "second@example.net", imf_raw, seen, None)
.await
.context("Failed to fully download end call message")?;
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Missed);
Ok(())
}

View File

@@ -12,7 +12,6 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
use chrono::TimeZone;
use deltachat_contact_tools::{ContactAddress, sanitize_bidi_characters, sanitize_single_line};
use deltachat_derive::{FromSql, ToSql};
use mail_builder::mime::MimePart;
use serde::{Deserialize, Serialize};
use strum_macros::EnumIter;
@@ -31,6 +30,7 @@ use crate::debug_logging::maybe_set_logging_xdc;
use crate::download::DownloadState;
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key::self_fingerprint;
use crate::location;
use crate::log::{LogExt, error, info, warn};
use crate::logged_debug_assert;
@@ -67,41 +67,6 @@ pub enum ChatItem {
},
}
/// Chat protection status.
#[derive(
Debug,
Default,
Display,
Clone,
Copy,
PartialEq,
Eq,
FromPrimitive,
ToPrimitive,
FromSql,
ToSql,
IntoStaticStr,
Serialize,
Deserialize,
)]
#[repr(u32)]
pub enum ProtectionStatus {
/// Chat is not protected.
#[default]
Unprotected = 0,
/// Chat is protected.
///
/// All members of the chat must be verified.
Protected = 1,
// `2` was never used as a value.
// Chats don't break in Core v2 anymore. Chats with broken protection existing before the
// key-contacts migration are treated as `Unprotected`.
//
// ProtectionBroken = 3,
}
/// The reason why messages cannot be sent to the chat.
///
/// The reason is mainly for logging and displaying in debug REPL, thus not translated.
@@ -306,14 +271,12 @@ impl ChatId {
/// Create a group or mailinglist raw database record with the given parameters.
/// The function does not add SELF nor checks if the record already exists.
#[expect(clippy::too_many_arguments)]
pub(crate) async fn create_multiuser_record(
context: &Context,
chattype: Chattype,
grpid: &str,
grpname: &str,
create_blocked: Blocked,
create_protected: ProtectionStatus,
param: Option<String>,
timestamp: i64,
) -> Result<Self> {
@@ -321,31 +284,27 @@ 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(?, ?, ?, ?, ?, ?, ?);",
"INSERT INTO chats (type, name, grpid, blocked, created_timestamp, protected, param) VALUES(?, ?, ?, ?, ?, 0, ?);",
(
chattype,
&grpname,
grpid,
create_blocked,
timestamp,
create_protected,
param.unwrap_or_default(),
),
).await?;
let chat_id = ChatId::new(u32::try_from(row_id)?);
let chat = Chat::load_from_db(context, chat_id).await?;
if create_protected == ProtectionStatus::Protected {
chat_id
.add_protection_msg(context, ProtectionStatus::Protected, None, timestamp)
.await?;
} else {
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
if chat.is_encrypted(context).await? {
chat_id.add_encrypted_msg(context, timestamp).await?;
}
info!(
context,
"Created group/mailinglist '{}' grpid={} as {}, blocked={}, protected={create_protected}.",
"Created group/mailinglist '{}' grpid={} as {}, blocked={}.",
&grpname,
grpid,
chat_id,
@@ -373,7 +332,7 @@ impl ChatId {
/// Returns true if the value was modified.
pub(crate) async fn set_blocked(self, context: &Context, new_blocked: Blocked) -> Result<bool> {
if self.is_special() {
bail!("ignoring setting of Block-status for {}", self);
bail!("ignoring setting of Block-status for {self}");
}
let count = context
.sql
@@ -500,111 +459,8 @@ impl ChatId {
Ok(())
}
/// Sets protection without sending a message.
///
/// Returns whether the protection status was actually modified.
pub(crate) async fn inner_set_protection(
self,
context: &Context,
protect: ProtectionStatus,
) -> Result<bool> {
ensure!(!self.is_special(), "Invalid chat-id {self}.");
let chat = Chat::load_from_db(context, self).await?;
if protect == chat.protected {
info!(context, "Protection status unchanged for {}.", self);
return Ok(false);
}
match protect {
ProtectionStatus::Protected => match chat.typ {
Chattype::Single
| Chattype::Group
| Chattype::OutBroadcast
| Chattype::InBroadcast => {}
Chattype::Mailinglist => bail!("Cannot protect mailing lists"),
},
ProtectionStatus::Unprotected => {}
};
context
.sql
.execute("UPDATE chats SET protected=? WHERE id=?;", (protect, self))
.await?;
context.emit_event(EventType::ChatModified(self));
chatlist_events::emit_chatlist_item_changed(context, self);
// make sure, the receivers will get all keys
self.reset_gossiped_timestamp(context).await?;
Ok(true)
}
/// Adds an info message to the chat, telling the user that the protection status changed.
///
/// Params:
///
/// * `contact_id`: In a 1:1 chat, pass the chat partner's contact id.
/// * `timestamp_sort` is used as the timestamp of the added message
/// and should be the timestamp of the change happening.
pub(crate) async fn add_protection_msg(
self,
context: &Context,
protect: ProtectionStatus,
contact_id: Option<ContactId>,
timestamp_sort: i64,
) -> Result<()> {
if contact_id == Some(ContactId::SELF) {
// Do not add protection messages to Saved Messages chat.
// This chat never gets protected and unprotected,
// we do not want the first message
// to be a protection message with an arbitrary timestamp.
return Ok(());
}
let text = context.stock_protection_msg(protect, contact_id).await;
let cmd = match protect {
ProtectionStatus::Protected => SystemMessage::ChatProtectionEnabled,
ProtectionStatus::Unprotected => SystemMessage::ChatProtectionDisabled,
};
add_info_msg_with_cmd(
context,
self,
&text,
cmd,
timestamp_sort,
None,
None,
None,
None,
)
.await?;
Ok(())
}
/// Adds message "Messages are end-to-end encrypted" if appropriate.
///
/// This function is rather slow because it does a lot of database queries,
/// but this is fine because it is only called on chat creation.
async fn maybe_add_encrypted_msg(self, context: &Context, timestamp_sort: i64) -> Result<()> {
let chat = Chat::load_from_db(context, self).await?;
// as secure-join adds its own message on success (after some other messasges),
// we do not want to add "Messages are end-to-end encrypted" on chat creation.
// we detect secure join by `can_send` (for Bob, scanner side) and by `blocked` (for Alice, inviter side) below.
if !chat.is_encrypted(context).await?
|| self <= DC_CHAT_ID_LAST_SPECIAL
|| chat.is_device_talk()
|| chat.is_self_talk()
|| (!chat.can_send(context).await? && !chat.is_contact_request())
|| chat.blocked == Blocked::Yes
{
return Ok(());
}
/// Adds message "Messages are end-to-end encrypted".
async fn add_encrypted_msg(self, context: &Context, timestamp_sort: i64) -> Result<()> {
let text = stock_str::messages_e2e_encrypted(context).await;
add_info_msg_with_cmd(
context,
@@ -621,74 +477,6 @@ impl ChatId {
Ok(())
}
/// Sets protection and adds a message.
///
/// `timestamp_sort` is used as the timestamp of the added message
/// and should be the timestamp of the change happening.
async fn set_protection_for_timestamp_sort(
self,
context: &Context,
protect: ProtectionStatus,
timestamp_sort: i64,
contact_id: Option<ContactId>,
) -> Result<()> {
let protection_status_modified = self
.inner_set_protection(context, protect)
.await
.with_context(|| format!("Cannot set protection for {self}"))?;
if protection_status_modified {
self.add_protection_msg(context, protect, contact_id, timestamp_sort)
.await?;
chatlist_events::emit_chatlist_item_changed(context, self);
}
Ok(())
}
/// Sets protection and sends or adds a message.
///
/// `timestamp_sent` is the "sent" timestamp of a message caused the protection state change.
pub(crate) async fn set_protection(
self,
context: &Context,
protect: ProtectionStatus,
timestamp_sent: i64,
contact_id: Option<ContactId>,
) -> Result<()> {
let sort_to_bottom = true;
let (received, incoming) = (false, false);
let ts = self
.calc_sort_timestamp(context, timestamp_sent, sort_to_bottom, received, incoming)
.await?
// Always sort protection messages below `SystemMessage::SecurejoinWait{,Timeout}` ones
// in case of race conditions.
.saturating_add(1);
self.set_protection_for_timestamp_sort(context, protect, ts, contact_id)
.await
}
/// Sets the 1:1 chat with the given address to ProtectionStatus::Protected,
/// and posts a `SystemMessage::ChatProtectionEnabled` into it.
///
/// If necessary, creates a hidden chat for this.
pub(crate) async fn set_protection_for_contact(
context: &Context,
contact_id: ContactId,
timestamp: i64,
) -> Result<()> {
let chat_id = ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Yes)
.await
.with_context(|| format!("can't create chat for {contact_id}"))?;
chat_id
.set_protection(
context,
ProtectionStatus::Protected,
timestamp,
Some(contact_id),
)
.await?;
Ok(())
}
/// Archives or unarchives a chat.
pub async fn set_visibility(self, context: &Context, visibility: ChatVisibility) -> Result<()> {
self.set_visibility_ex(context, Sync, visibility).await
@@ -702,8 +490,7 @@ impl ChatId {
) -> Result<()> {
ensure!(
!self.is_special(),
"bad chat_id, can not be special chat: {}",
self
"bad chat_id, can not be special chat: {self}"
);
context
@@ -813,8 +600,7 @@ impl ChatId {
pub(crate) async fn delete_ex(self, context: &Context, sync: sync::Sync) -> Result<()> {
ensure!(
!self.is_special(),
"bad chat_id, can not be a special chat: {}",
self
"bad chat_id, can not be a special chat: {self}"
);
let chat = Chat::load_from_db(context, self).await?;
@@ -1139,9 +925,9 @@ impl ChatId {
/// Chat is considered active if something was posted there within the last 42 days.
pub async fn get_similar_chat_ids(self, context: &Context) -> Result<Vec<(ChatId, f64)>> {
// Count number of common members in this and other chats.
let intersection: Vec<(ChatId, f64)> = context
let intersection = context
.sql
.query_map(
.query_map_vec(
"SELECT y.chat_id, SUM(x.contact_id = y.contact_id)
FROM chats_contacts as x
JOIN chats_contacts as y
@@ -1159,17 +945,13 @@ impl ChatId {
let intersection: f64 = row.get(1)?;
Ok((chat_id, intersection))
},
|rows| {
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await
.context("failed to calculate member set intersections")?;
let chat_size: HashMap<ChatId, f64> = context
.sql
.query_map(
.query_map_collect(
"SELECT chat_id, count(*) AS n
FROM chats_contacts
WHERE contact_id > ? AND chat_id > ?
@@ -1181,10 +963,6 @@ impl ChatId {
let size: f64 = row.get(1)?;
Ok((chat_id, size))
},
|rows| {
rows.collect::<std::result::Result<HashMap<ChatId, f64>, _>>()
.map_err(Into::into)
},
)
.await
.context("failed to count chat member sizes")?;
@@ -1398,16 +1176,6 @@ impl ChatId {
Ok(())
}
/// Returns true if the chat is protected.
pub async fn is_protected(self, context: &Context) -> Result<ProtectionStatus> {
let protection_status = context
.sql
.query_get_value("SELECT protected FROM chats WHERE id=?", (self,))
.await?
.unwrap_or_default();
Ok(protection_status)
}
/// Returns the sort timestamp for a new message in the chat.
///
/// `message_timestamp` should be either the message "sent" timestamp or a timestamp of the
@@ -1562,9 +1330,6 @@ pub struct Chat {
/// Duration of the chat being muted.
pub mute_duration: MuteDuration,
/// If the chat is protected (verified).
pub(crate) protected: ProtectionStatus,
}
impl Chat {
@@ -1574,7 +1339,7 @@ impl Chat {
.sql
.query_row(
"SELECT c.type, c.name, c.grpid, c.param, c.archived,
c.blocked, c.locations_send_until, c.muted_until, c.protected
c.blocked, c.locations_send_until, c.muted_until
FROM chats c
WHERE c.id=?;",
(chat_id,),
@@ -1589,7 +1354,6 @@ impl Chat {
blocked: row.get::<_, Option<_>>(5)?.unwrap_or_default(),
is_sending_locations: row.get(6)?,
mute_duration: row.get(7)?,
protected: row.get(8)?,
};
Ok(c)
},
@@ -1722,7 +1486,7 @@ impl Chat {
/// Checks if the user is part of a chat
/// and has basically the permissions to edit the chat therefore.
/// The function does not check if the chat type allows editing of concrete elements.
pub(crate) async fn is_self_in_chat(&self, context: &Context) -> Result<bool> {
pub async fn is_self_in_chat(&self, context: &Context) -> Result<bool> {
match self.typ {
Chattype::Single | Chattype::OutBroadcast | Chattype::Mailinglist => Ok(true),
Chattype::Group => is_contact_in_chat(context, self.id, ContactId::SELF).await,
@@ -1870,53 +1634,38 @@ impl Chat {
!self.is_unpromoted()
}
/// Returns true if chat protection is enabled.
///
/// UI should display a green checkmark
/// in the chat title,
/// in the chat profile title and
/// in the chatlist item
/// if chat protection is enabled.
/// UI should also display a green checkmark
/// in the contact profile
/// if 1:1 chat with this contact exists and is protected.
pub fn is_protected(&self) -> bool {
self.protected == ProtectionStatus::Protected
}
/// Returns true if the chat is encrypted.
pub async fn is_encrypted(&self, context: &Context) -> Result<bool> {
let is_encrypted = self.is_protected()
|| match self.typ {
Chattype::Single => {
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
let is_encrypted = match self.typ {
Chattype::Single => {
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
FROM chats_contacts cc LEFT JOIN contacts c
ON c.id=cc.contact_id
WHERE cc.chat_id=?
",
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
}
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
}
Chattype::Group => {
// Do not encrypt ad-hoc groups.
!self.grpid.is_empty()
}
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => true,
};
}
Chattype::Group => {
// Do not encrypt ad-hoc groups.
!self.grpid.is_empty()
}
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => true,
};
Ok(is_encrypted)
}
@@ -2250,20 +1999,23 @@ impl Chat {
/// Sends a `SyncAction` synchronising chat contacts to other devices.
pub(crate) async fn sync_contacts(&self, context: &Context) -> Result<()> {
if self.is_encrypted(context).await? {
let self_fp = self_fingerprint(context).await?;
let fingerprint_addrs = context
.sql
.query_map(
"SELECT c.fingerprint, c.addr
.query_map_vec(
"SELECT c.id, c.fingerprint, c.addr
FROM contacts c INNER JOIN chats_contacts cc
ON c.id=cc.contact_id
WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp",
(self.id,),
|row| {
let fingerprint = row.get(0)?;
let addr = row.get(1)?;
if row.get::<_, ContactId>(0)? == ContactId::SELF {
return Ok((self_fp.to_string(), String::new()));
}
let fingerprint = row.get(1)?;
let addr = row.get(2)?;
Ok((fingerprint, addr))
},
|addrs| addrs.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
self.sync(context, SyncAction::SetPgpContacts(fingerprint_addrs))
@@ -2271,14 +2023,13 @@ impl Chat {
} else {
let addrs = context
.sql
.query_map(
.query_map_vec(
"SELECT c.addr \
FROM contacts c INNER JOIN chats_contacts cc \
ON c.id=cc.contact_id \
WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp",
(self.id,),
|row| row.get::<_, String>(0),
|addrs| addrs.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
self.sync(context, SyncAction::SetContacts(addrs)).await?;
@@ -2627,7 +2378,6 @@ impl ChatIdBlocked {
_ => (),
}
let protected = contact_id == ContactId::SELF || contact.is_verified(context).await?;
let smeared_time = create_smeared_timestamp(context);
let chat_id = context
@@ -2635,19 +2385,14 @@ impl ChatIdBlocked {
.transaction(move |transaction| {
transaction.execute(
"INSERT INTO chats
(type, name, param, blocked, created_timestamp, protected)
VALUES(?, ?, ?, ?, ?, ?)",
(type, name, param, blocked, created_timestamp)
VALUES(?, ?, ?, ?, ?)",
(
Chattype::Single,
chat_name,
params.to_string(),
create_blocked as u8,
smeared_time,
if protected {
ProtectionStatus::Protected
} else {
ProtectionStatus::Unprotected
},
),
)?;
let chat_id = ChatId::new(
@@ -2668,19 +2413,12 @@ impl ChatIdBlocked {
})
.await?;
if protected {
chat_id
.add_protection_msg(
context,
ProtectionStatus::Protected,
Some(contact_id),
smeared_time,
)
.await?;
} else {
chat_id
.maybe_add_encrypted_msg(context, smeared_time)
.await?;
let chat = Chat::load_from_db(context, chat_id).await?;
if chat.is_encrypted(context).await?
&& !chat.param.exists(Param::Devicetalk)
&& !chat.param.exists(Param::Selftalk)
{
chat_id.add_encrypted_msg(context, smeared_time).await?;
}
Ok(Self {
@@ -3145,8 +2883,7 @@ pub async fn send_text_msg(
) -> Result<MsgId> {
ensure!(
!chat_id.is_special(),
"bad chat_id, can not be a special chat: {}",
chat_id
"bad chat_id, can not be a special chat: {chat_id}"
);
let mut msg = Message::new_text(text_to_send);
@@ -3378,13 +3115,12 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
if chat_id.is_archived_link() {
let chat_ids_in_archive = context
.sql
.query_map(
.query_map_vec(
"SELECT DISTINCT(m.chat_id) FROM msgs m
LEFT JOIN chats c ON m.chat_id=c.id
WHERE m.state=10 AND m.hidden=0 AND m.chat_id>9 AND c.archived=1",
(),
|row| row.get::<_, ChatId>(0),
|ids| ids.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
if chat_ids_in_archive.is_empty() {
@@ -3428,7 +3164,7 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
// locally (i.e. when the chat was opened locally).
let hidden_messages = context
.sql
.query_map(
.query_map_vec(
"SELECT id, rfc724_mid FROM msgs
WHERE state=?
AND hidden=1
@@ -3440,10 +3176,6 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
let rfc724_mid: String = row.get(1)?;
Ok((msg_id, rfc724_mid))
},
|rows| {
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await?;
for (msg_id, rfc724_mid) in &hidden_messages {
@@ -3552,7 +3284,7 @@ pub async fn get_chat_media(
{
context
.sql
.query_map(
.query_map_vec(
"SELECT id
FROM msgs
WHERE (1=? OR chat_id=?)
@@ -3567,13 +3299,12 @@ pub async fn get_chat_media(
Viewtype::Webxdc,
),
|row| row.get::<_, MsgId>(0),
|ids| Ok(ids.flatten().collect()),
)
.await?
} else {
context
.sql
.query_map(
.query_map_vec(
"SELECT id
FROM msgs
WHERE (1=? OR chat_id=?)
@@ -3598,7 +3329,6 @@ pub async fn get_chat_media(
},
),
|row| row.get::<_, MsgId>(0),
|ids| Ok(ids.flatten().collect()),
)
.await?
};
@@ -3609,10 +3339,9 @@ pub async fn get_chat_media(
pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec<ContactId>> {
// Normal chats do not include SELF. Group chats do (as it may happen that one is deleted from a
// groupchat but the chats stays visible, moreover, this makes displaying lists easier)
let list = context
context
.sql
.query_map(
.query_map_vec(
"SELECT cc.contact_id
FROM chats_contacts cc
LEFT JOIN contacts c
@@ -3621,11 +3350,8 @@ pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec
ORDER BY c.id=1, c.last_seen DESC, c.id DESC;",
(chat_id,),
|row| row.get::<_, ContactId>(0),
|ids| ids.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
Ok(list)
.await
}
/// Returns a vector of contact IDs for given chat ID that are no longer part of the group.
@@ -3633,9 +3359,9 @@ pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec
/// Members that have been removed recently are in the beginning of the list.
pub async fn get_past_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec<ContactId>> {
let now = time();
let list = context
context
.sql
.query_map(
.query_map_vec(
"SELECT cc.contact_id
FROM chats_contacts cc
LEFT JOIN contacts c
@@ -3646,30 +3372,30 @@ pub async fn get_past_chat_contacts(context: &Context, chat_id: ChatId) -> Resul
ORDER BY c.id=1, cc.remove_timestamp DESC, c.id DESC",
(chat_id, now.saturating_sub(60 * 24 * 3600)),
|row| row.get::<_, ContactId>(0),
|ids| ids.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
Ok(list)
.await
}
/// Creates a group chat with a given `name`.
/// Deprecated on 2025-06-21, use `create_group_ex()`.
pub async fn create_group_chat(
context: &Context,
protect: ProtectionStatus,
name: &str,
) -> Result<ChatId> {
create_group_ex(context, Some(protect), name).await
/// Creates an encrypted group chat.
pub async fn create_group(context: &Context, name: &str) -> Result<ChatId> {
create_group_ex(context, Sync, create_id(), name).await
}
/// Creates an unencrypted group chat.
pub async fn create_group_unencrypted(context: &Context, name: &str) -> Result<ChatId> {
create_group_ex(context, Sync, String::new(), name).await
}
/// Creates a group chat.
///
/// * `encryption` - If `Some`, the chat is encrypted (with key-contacts) and can be protected.
/// * `sync` - Whether a multi-device synchronization message should be sent. Ignored for
/// unencrypted chats currently.
/// * `grpid` - Group ID. Iff nonempty, the chat is encrypted (with key-contacts).
/// * `name` - Chat name.
pub async fn create_group_ex(
pub(crate) async fn create_group_ex(
context: &Context,
encryption: Option<ProtectionStatus>,
sync: sync::Sync,
grpid: String,
name: &str,
) -> Result<ChatId> {
let mut chat_name = sanitize_single_line(name);
@@ -3680,11 +3406,6 @@ pub async fn create_group_ex(
chat_name = "".to_string();
}
let grpid = match encryption {
Some(_) => create_id(),
None => String::new(),
};
let timestamp = create_smeared_timestamp(context);
let row_id = context
.sql
@@ -3692,7 +3413,7 @@ pub async fn create_group_ex(
"INSERT INTO chats
(type, name, grpid, param, created_timestamp)
VALUES(?, ?, ?, \'U=1\', ?);",
(Chattype::Group, chat_name, grpid, timestamp),
(Chattype::Group, &chat_name, &grpid, timestamp),
)
.await?;
@@ -3703,19 +3424,9 @@ pub async fn create_group_ex(
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
match encryption {
Some(ProtectionStatus::Protected) => {
let protect = ProtectionStatus::Protected;
chat_id
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
.await?;
}
Some(ProtectionStatus::Unprotected) => {
// Add "Messages are end-to-end encrypted." message
// even to unprotected chats.
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
}
None => {}
if !grpid.is_empty() {
// Add "Messages are end-to-end encrypted." message.
chat_id.add_encrypted_msg(context, timestamp).await?;
}
if !context.get_config_bool(Config::Bot).await?
@@ -3724,7 +3435,11 @@ pub async fn create_group_ex(
let text = stock_str::new_group_send_first_message(context).await;
add_info_msg(context, chat_id, &text, create_smeared_timestamp(context)).await?;
}
if let (true, true) = (sync.into(), !grpid.is_empty()) {
let id = SyncId::Grpid(grpid);
let action = SyncAction::CreateGroupEncrypted(chat_name);
self::sync(context, id, action).await.log_err(context).ok();
}
Ok(chat_id)
}
@@ -3740,7 +3455,7 @@ pub async fn create_group_ex(
/// which would make it hard to grep for it.
///
/// After creation, the chat contains no recipients and is in _unpromoted_ state;
/// see [`create_group_chat`] for more information on the unpromoted state.
/// see [`create_group`] for more information on the unpromoted state.
///
/// Returns the created chat's id.
pub async fn create_broadcast(context: &Context, chat_name: String) -> Result<ChatId> {
@@ -3914,13 +3629,11 @@ pub(crate) async fn add_contact_to_chat_ex(
let mut chat = Chat::load_from_db(context, chat_id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"{} is not a group/broadcast where one can add members",
chat_id
"{chat_id} is not a group/broadcast where one can add members"
);
ensure!(
Contact::real_exists_by_id(context, contact_id).await? || contact_id == ContactId::SELF,
"invalid contact_id {} for adding to group",
contact_id
"invalid contact_id {contact_id} for adding to group"
);
ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed");
ensure!(
@@ -3966,13 +3679,6 @@ pub(crate) async fn add_contact_to_chat_ex(
}
} else {
// else continue and send status mail
if chat.is_protected() && !contact.is_verified(context).await? {
error!(
context,
"Cannot add non-bidirectionally verified contact {contact_id} to protected chat {chat_id}."
);
return Ok(false);
}
if is_contact_in_chat(context, chat_id, contact_id).await? {
return Ok(false);
}
@@ -4013,12 +3719,12 @@ pub(crate) async fn add_contact_to_chat_ex(
Ok(true)
}
/// Returns true if an avatar should be attached in the given chat.
/// Returns whether profile data should be attached when sending to the given chat.
///
/// This function does not check if the avatar is set.
/// This function does not check if the avatar/status is set.
/// If avatar is not set and this function returns `true`,
/// a `Chat-User-Avatar: 0` header should be sent to reset the avatar.
pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId) -> Result<bool> {
pub(crate) async fn should_attach_profile(context: &Context, chat_id: ChatId) -> Result<bool> {
let timestamp_some_days_ago = time() - DC_RESEND_USER_AVATAR_DAYS * 24 * 60 * 60;
let needs_attach = context
.sql
@@ -4033,8 +3739,8 @@ pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId)
let mut needs_attach = false;
for row in rows {
let row = row?;
let selfavatar_sent = row?;
if selfavatar_sent < timestamp_some_days_ago {
let profile_sent = row?;
if profile_sent < timestamp_some_days_ago {
needs_attach = true;
}
}
@@ -4133,8 +3839,7 @@ pub async fn remove_contact_from_chat(
) -> Result<()> {
ensure!(
!chat_id.is_special(),
"bad chat_id, can not be special chat: {}",
chat_id
"bad chat_id, can not be special chat: {chat_id}"
);
ensure!(
!contact_id.is_special() || contact_id == ContactId::SELF,
@@ -4148,7 +3853,7 @@ pub async fn remove_contact_from_chat(
"Cannot remove contact {contact_id} from chat {chat_id}: self not in group."
);
context.emit_event(EventType::ErrorSelfNotInGroup(err_msg.clone()));
bail!("{}", err_msg);
bail!("{err_msg}");
} else {
let mut sync = Nosync;
@@ -4368,7 +4073,7 @@ pub async fn set_chat_profile_image(
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
}
chat.update_param(context).await?;
if chat.is_promoted() && !chat.is_mailing_list() {
if chat.is_promoted() {
msg.id = send_msg(context, chat_id, &mut msg).await?;
context.emit_msgs_changed(chat_id, msg.id);
}
@@ -4390,7 +4095,7 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
.await?;
let mut chat = Chat::load_from_db(context, chat_id).await?;
if let Some(reason) = chat.why_cant_send(context).await? {
bail!("cannot send to {}: {}", chat_id, reason);
bail!("cannot send to {chat_id}: {reason}");
}
curr_timestamp = create_smeared_timestamps(context, msg_ids.len());
let mut msgs = Vec::with_capacity(msg_ids.len());
@@ -4633,24 +4338,21 @@ pub(crate) async fn get_chat_cnt(context: &Context) -> Result<usize> {
}
}
/// Returns a tuple of `(chatid, is_protected, blocked)`.
/// Returns a tuple of `(chatid, blocked)`.
pub(crate) async fn get_chat_id_by_grpid(
context: &Context,
grpid: &str,
) -> Result<Option<(ChatId, bool, Blocked)>> {
) -> Result<Option<(ChatId, Blocked)>> {
context
.sql
.query_row_optional(
"SELECT id, blocked, protected FROM chats WHERE grpid=?;",
"SELECT id, blocked FROM chats WHERE grpid=?;",
(grpid,),
|row| {
let chat_id = row.get::<_, ChatId>(0)?;
let b = row.get::<_, Option<Blocked>>(1)?.unwrap_or_default();
let p = row
.get::<_, Option<ProtectionStatus>>(2)?
.unwrap_or_default();
Ok((chat_id, p == ProtectionStatus::Protected, b))
Ok((chat_id, b))
},
)
.await
@@ -4953,16 +4655,14 @@ async fn set_contacts_by_fingerprints(
"Cannot add key-contacts to unencrypted chat {id}"
);
ensure!(
chat.typ == Chattype::OutBroadcast,
"{id} is not a broadcast list",
matches!(chat.typ, Chattype::Group | Chattype::OutBroadcast),
"{id} is not a group or broadcast",
);
let mut contacts = HashSet::new();
for (fingerprint, addr) in fingerprint_addrs {
let contact_addr = ContactAddress::new(addr)?;
let contact =
Contact::add_or_lookup_ex(context, "", &contact_addr, fingerprint, Origin::Hidden)
.await?
.0;
let contact = Contact::add_or_lookup_ex(context, "", addr, fingerprint, Origin::Hidden)
.await?
.0;
contacts.insert(contact);
}
let contacts_old = HashSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
@@ -5001,7 +4701,7 @@ pub(crate) enum SyncId {
/// "Message-ID"-s, from oldest to latest. Used for ad-hoc groups.
Msgids(Vec<String>),
// Special id for device chat.
/// Special id for device chat.
Device,
}
@@ -5015,6 +4715,8 @@ pub(crate) enum SyncAction {
SetMuted(MuteDuration),
/// Create broadcast channel with the given name.
CreateBroadcast(String),
/// Create encrypted group chat with the given name.
CreateGroupEncrypted(String),
Rename(String),
/// Set chat contacts by their addresses.
SetContacts(Vec<String>),
@@ -5080,6 +4782,9 @@ impl Context {
if let SyncAction::CreateBroadcast(name) = action {
create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?;
return Ok(());
} else if let SyncAction::CreateGroupEncrypted(name) = action {
create_group_ex(self, Nosync, grpid.clone(), name).await?;
return Ok(());
}
get_chat_id_by_grpid(self, grpid)
.await?
@@ -5101,7 +4806,7 @@ impl Context {
SyncAction::Accept => chat_id.accept_ex(self, Nosync).await,
SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await,
SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await,
SyncAction::CreateBroadcast(_) => {
SyncAction::CreateBroadcast(_) | SyncAction::CreateGroupEncrypted(..) => {
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
}
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,

View File

@@ -34,7 +34,7 @@ async fn test_chat_info() {
"archived": false,
"param": "",
"is_sending_locations": false,
"color": 29377,
"color": 29381,
"profile_image": {},
"draft": "",
"is_muted": false,
@@ -96,7 +96,7 @@ async fn test_get_draft() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_draft() -> Result<()> {
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "abc").await?;
let chat_id = create_group(&t, "abc").await?;
let mut msg = Message::new_text("hi!".to_string());
chat_id.set_draft(&t, Some(&mut msg)).await?;
@@ -120,7 +120,7 @@ async fn test_forwarding_draft_failing() -> Result<()> {
chat_id.set_draft(&t, Some(&mut msg)).await?;
assert_eq!(msg.id, chat_id.get_draft(&t).await?.unwrap().id);
let chat_id2 = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let chat_id2 = create_group(&t, "foo").await?;
assert!(forward_msgs(&t, &[msg.id], chat_id2).await.is_err());
Ok(())
}
@@ -169,7 +169,7 @@ async fn test_draft_stable_ids() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_only_one_draft_per_chat() -> Result<()> {
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "abc").await?;
let chat_id = create_group(&t, "abc").await?;
let msgs: Vec<message::Message> = (1..=1000)
.map(|i| Message::new_text(i.to_string()))
@@ -196,7 +196,7 @@ async fn test_only_one_draft_per_chat() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_change_quotes_on_reused_message_object() -> Result<()> {
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "chat").await?;
let chat_id = create_group(&t, "chat").await?;
let quote1 =
Message::load_from_db(&t, send_text_msg(&t, chat_id, "quote1".to_string()).await?).await?;
let quote2 =
@@ -247,7 +247,7 @@ async fn test_quote_replies() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let grp_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "grp").await?;
let grp_chat_id = create_group(&alice, "grp").await?;
let grp_msg_id = send_text_msg(&alice, grp_chat_id, "bar".to_string()).await?;
let grp_msg = Message::load_from_db(&alice, grp_msg_id).await?;
@@ -295,9 +295,7 @@ async fn test_quote_replies() -> Result<()> {
async fn test_add_contact_to_chat_ex_add_self() {
// Adding self to a contact should succeed, even though it's pointless.
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo")
.await
.unwrap();
let chat_id = create_group(&t, "foo").await.unwrap();
let added = add_contact_to_chat_ex(&t, Nosync, chat_id, ContactId::SELF, false)
.await
.unwrap();
@@ -336,8 +334,7 @@ async fn test_member_add_remove() -> Result<()> {
}
tcm.section("Create and promote a group.");
let alice_chat_id =
create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(&alice, "Group chat").await?;
let alice_fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await;
add_contact_to_chat(&alice, alice_chat_id, alice_fiona_contact_id).await?;
let sent = alice
@@ -399,8 +396,7 @@ async fn test_parallel_member_remove() -> Result<()> {
let alice_charlie_contact_id = alice.add_or_lookup_contact_id(&charlie).await;
tcm.section("Alice creates and promotes a group");
let alice_chat_id =
create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(&alice, "Group chat").await?;
add_contact_to_chat(&alice, alice_chat_id, alice_bob_contact_id).await?;
add_contact_to_chat(&alice, alice_chat_id, alice_fiona_contact_id).await?;
let alice_sent_msg = alice
@@ -457,8 +453,7 @@ async fn test_msg_with_implicit_member_removed() -> Result<()> {
let alice_bob_contact_id = alice.add_or_lookup_contact_id(&bob).await;
let alice_fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await;
let bob_fiona_contact_id = bob.add_or_lookup_contact_id(&fiona).await;
let alice_chat_id =
create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(&alice, "Group chat").await?;
add_contact_to_chat(&alice, alice_chat_id, alice_bob_contact_id).await?;
let sent_msg = alice.send_text(alice_chat_id, "I created a group").await;
let bob_received_msg = bob.recv_msg(&sent_msg).await;
@@ -488,7 +483,7 @@ async fn test_msg_with_implicit_member_removed() -> Result<()> {
// If Bob sends a message to Alice now, Fiona is removed.
assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 3);
let sent_msg = bob
.send_text(alice_chat_id, "I have removed Fiona some time ago.")
.send_text(bob_chat_id, "I have removed Fiona some time ago.")
.await;
alice.recv_msg(&sent_msg).await;
assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 2);
@@ -504,7 +499,7 @@ async fn test_modify_chat_multi_device() -> Result<()> {
a1.set_config_bool(Config::BccSelf, true).await?;
// create group and sync it to the second device
let a1_chat_id = create_group_chat(&a1, ProtectionStatus::Unprotected, "foo").await?;
let a1_chat_id = create_group(&a1, "foo").await?;
let sent = a1.send_text(a1_chat_id, "ho!").await;
let a1_msg = a1.get_last_msg().await;
let a1_chat = Chat::load_from_db(&a1, a1_chat_id).await?;
@@ -602,7 +597,7 @@ async fn test_modify_chat_disordered() -> Result<()> {
let fiona = tcm.fiona().await;
let fiona_id = alice.add_or_lookup_contact_id(&fiona).await;
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
let alice_chat_id = create_group(&alice, "foo").await?;
send_text_msg(&alice, alice_chat_id, "populate".to_string()).await?;
add_contact_to_chat(&alice, alice_chat_id, bob_id).await?;
@@ -649,9 +644,7 @@ async fn test_lost_member_added() -> Result<()> {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let alice_chat_id = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob])
.await;
let alice_chat_id = alice.create_group_with_members("Group", &[bob]).await;
let alice_sent = alice.send_text(alice_chat_id, "Hi!").await;
let bob_chat_id = bob.recv_msg(&alice_sent).await.chat_id;
assert_eq!(get_chat_contacts(bob, bob_chat_id).await?.len(), 2);
@@ -681,7 +674,7 @@ async fn test_modify_chat_lost() -> Result<()> {
let fiona = tcm.fiona().await;
let fiona_id = alice.add_or_lookup_contact_id(&fiona).await;
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
let alice_chat_id = create_group(&alice, "foo").await?;
add_contact_to_chat(&alice, alice_chat_id, bob_id).await?;
add_contact_to_chat(&alice, alice_chat_id, charlie_id).await?;
add_contact_to_chat(&alice, alice_chat_id, fiona_id).await?;
@@ -722,7 +715,7 @@ async fn test_leave_group() -> Result<()> {
let bob = tcm.bob().await;
tcm.section("Alice creates group chat with Bob.");
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
let alice_chat_id = create_group(&alice, "foo").await?;
let bob_contact = alice.add_or_lookup_contact(&bob).await.id;
add_contact_to_chat(&alice, alice_chat_id, bob_contact).await?;
@@ -1381,9 +1374,7 @@ async fn test_pinned() {
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
let chat_id2 = t.get_self_chat().await.id;
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
let chat_id3 = create_group_chat(&t, ProtectionStatus::Unprotected, "foo")
.await
.unwrap();
let chat_id3 = create_group(&t, "foo").await.unwrap();
let chatlist = get_chats_from_chat_list(&t, DC_GCL_NO_SPECIALS).await;
assert_eq!(chatlist, vec![chat_id3, chat_id2, chat_id1]);
@@ -1473,9 +1464,7 @@ async fn test_set_chat_name() {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo")
.await
.unwrap();
let chat_id = create_group(alice, "foo").await.unwrap();
assert_eq!(
Chat::load_from_db(alice, chat_id).await.unwrap().get_name(),
"foo"
@@ -1542,23 +1531,23 @@ async fn test_create_same_chat_twice() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_shall_attach_selfavatar() -> Result<()> {
async fn test_should_attach_profile() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo").await?;
assert!(!shall_attach_selfavatar(alice, chat_id).await?);
let chat_id = create_group(alice, "foo").await?;
assert!(!should_attach_profile(alice, chat_id).await?);
let contact_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, chat_id, contact_id).await?;
assert!(shall_attach_selfavatar(alice, chat_id).await?);
assert!(should_attach_profile(alice, chat_id).await?);
chat_id.set_selfavatar_timestamp(alice, time()).await?;
assert!(!shall_attach_selfavatar(alice, chat_id).await?);
assert!(!should_attach_profile(alice, chat_id).await?);
alice.set_config(Config::Selfavatar, None).await?; // setting to None also forces re-sending
assert!(shall_attach_selfavatar(alice, chat_id).await?);
assert!(should_attach_profile(alice, chat_id).await?);
Ok(())
}
@@ -1569,7 +1558,7 @@ async fn test_profile_data_on_group_leave() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = create_group_chat(t, ProtectionStatus::Unprotected, "foo").await?;
let chat_id = create_group(t, "foo").await?;
let contact_id = t.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(t, chat_id, contact_id).await?;
@@ -1582,7 +1571,7 @@ async fn test_profile_data_on_group_leave() -> Result<()> {
tokio::fs::write(&file, bytes).await?;
t.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
.await?;
assert!(shall_attach_selfavatar(t, chat_id).await?);
assert!(should_attach_profile(t, chat_id).await?);
remove_contact_from_chat(t, chat_id, ContactId::SELF).await?;
let sent_msg = t.pop_sent_msg().await;
@@ -1594,9 +1583,7 @@ async fn test_profile_data_on_group_leave() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_mute_duration() {
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo")
.await
.unwrap();
let chat_id = create_group(&t, "foo").await.unwrap();
// Initial
assert_eq!(
Chat::load_from_db(&t, chat_id).await.unwrap().is_muted(),
@@ -1645,7 +1632,7 @@ async fn test_set_mute_duration() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_add_info_msg() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let chat_id = create_group(&t, "foo").await?;
add_info_msg(&t, chat_id, "foo info", time()).await?;
let msg = t.get_last_msg_in(chat_id).await;
@@ -1662,7 +1649,7 @@ async fn test_add_info_msg() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_add_info_msg_with_cmd() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let chat_id = create_group(&t, "foo").await?;
let msg_id = add_info_msg_with_cmd(
&t,
chat_id,
@@ -1931,14 +1918,14 @@ async fn test_classic_email_chat() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_get_color() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group_ex(&t, None, "a chat").await?;
let chat_id = create_group_unencrypted(&t, "a chat").await?;
let color1 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
assert_eq!(color1, 0x613dd7);
assert_eq!(color1, 0x6239dc);
// upper-/lowercase makes a difference for the colors, these are different groups
// (in contrast to email addresses, where upper-/lowercase is ignored in practise)
let t = TestContext::new().await;
let chat_id = create_group_ex(&t, None, "A CHAT").await?;
let chat_id = create_group_unencrypted(&t, "A CHAT").await?;
let color2 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
assert_ne!(color2, color1);
Ok(())
@@ -1948,7 +1935,7 @@ async fn test_chat_get_color() -> Result<()> {
async fn test_chat_get_color_encrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = create_group_ex(t, Some(ProtectionStatus::Unprotected), "a chat").await?;
let chat_id = create_group(t, "a chat").await?;
let color1 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
set_chat_name(t, chat_id, "A CHAT").await?;
let color2 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
@@ -2137,7 +2124,7 @@ async fn test_forward_info_msg() -> Result<()> {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id1 = create_group_chat(alice, ProtectionStatus::Unprotected, "a").await?;
let chat_id1 = create_group(alice, "a").await?;
send_text_msg(alice, chat_id1, "msg one".to_string()).await?;
let bob_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, chat_id1, bob_id).await?;
@@ -2204,8 +2191,7 @@ async fn test_forward_group() -> Result<()> {
let bob_chat = bob.create_chat(&alice).await;
// Alice creates a group with Bob.
let alice_group_chat_id =
create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
let alice_group_chat_id = create_group(&alice, "Group").await?;
let bob_id = alice.add_or_lookup_contact_id(&bob).await;
let charlie_id = alice.add_or_lookup_contact_id(&charlie).await;
add_contact_to_chat(&alice, alice_group_chat_id, bob_id).await?;
@@ -2257,8 +2243,7 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
.set_config(Config::Displayname, Some("secretname"))
.await?;
let bob_id = alice.add_or_lookup_contact_id(&bob).await;
let group_id =
create_group_chat(&alice, ProtectionStatus::Unprotected, "secretgrpname").await?;
let group_id = create_group(&alice, "secretgrpname").await?;
add_contact_to_chat(&alice, group_id, bob_id).await?;
let mut msg = Message::new_text("bla foo".to_owned());
let sent_msg = alice.send_msg(group_id, &mut msg).await;
@@ -2273,7 +2258,7 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
let orig_msg = bob.recv_msg(&sent_msg).await;
let charlie_id = bob.add_or_lookup_contact_id(&charlie).await;
let single_id = ChatId::create_for_contact(&bob, charlie_id).await?;
let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?;
let group_id = create_group(&bob, "group2").await?;
add_contact_to_chat(&bob, group_id, charlie_id).await?;
let broadcast_id = create_broadcast(&bob, "Channel".to_string()).await?;
add_contact_to_chat(&bob, broadcast_id, charlie_id).await?;
@@ -2371,7 +2356,7 @@ async fn test_save_msgs_order() -> Result<()> {
for a in [alice, alice1] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
let chat_id = create_group_chat(alice, ProtectionStatus::Protected, "grp").await?;
let chat_id = create_group(alice, "grp").await?;
let sent = [
alice.send_text(chat_id, "0").await,
alice.send_text(chat_id, "1").await,
@@ -2427,14 +2412,14 @@ async fn test_forward_from_saved_to_saved() -> Result<()> {
let bob = TestContext::new_bob().await;
let sent = alice.send_text(alice.create_chat(&bob).await.id, "k").await;
bob.recv_msg(&sent).await;
let received_message = bob.recv_msg(&sent).await;
let orig = bob.get_last_msg().await;
let self_chat = bob.get_self_chat().await;
save_msgs(&bob, &[orig.id]).await?;
let saved1 = bob.get_last_msg().await;
assert_eq!(
saved1.get_original_msg_id(&bob).await?.unwrap(),
sent.sender_msg_id
received_message.id
);
assert_ne!(saved1.from_id, ContactId::SELF);
@@ -2492,7 +2477,7 @@ async fn test_resend_own_message() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let fiona = TestContext::new_fiona().await;
let alice_grp = create_group_chat(&alice, ProtectionStatus::Unprotected, "grp").await?;
let alice_grp = create_group(&alice, "grp").await?;
add_contact_to_chat(
&alice,
alice_grp,
@@ -2579,7 +2564,7 @@ async fn test_resend_foreign_message_fails() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_grp = create_group_chat(alice, ProtectionStatus::Unprotected, "grp").await?;
let alice_grp = create_group(alice, "grp").await?;
add_contact_to_chat(alice, alice_grp, alice.add_or_lookup_contact_id(bob).await).await?;
let sent1 = alice.send_text(alice_grp, "alice->bob").await;
@@ -2596,7 +2581,7 @@ async fn test_resend_info_message_fails() -> Result<()> {
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let alice_grp = create_group_chat(alice, ProtectionStatus::Unprotected, "grp").await?;
let alice_grp = create_group(alice, "grp").await?;
add_contact_to_chat(alice, alice_grp, alice.add_or_lookup_contact_id(bob).await).await?;
alice.send_text(alice_grp, "alice->bob").await;
@@ -2619,7 +2604,7 @@ async fn test_can_send_group() -> Result<()> {
let chat_id = ChatId::create_for_contact(&alice, bob).await?;
let chat = Chat::load_from_db(&alice, chat_id).await?;
assert!(chat.can_send(&alice).await?);
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
let chat_id = create_group(&alice, "foo").await?;
assert_eq!(
Chat::load_from_db(&alice, chat_id)
.await?
@@ -2659,7 +2644,7 @@ async fn test_broadcast() -> Result<()> {
add_contact_to_chat(
&alice,
broadcast_id,
get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(),
get_chat_contacts(&alice, msg.chat_id).await?.pop().unwrap(),
)
.await?;
let fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await;
@@ -3115,7 +3100,7 @@ async fn test_chat_get_encryption_info() -> Result<()> {
let contact_bob = alice.add_or_lookup_contact_id(bob).await;
let contact_fiona = alice.add_or_lookup_contact_id(fiona).await;
let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?;
let chat_id = create_group(alice, "Group").await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available"
@@ -3177,9 +3162,7 @@ async fn test_out_failed_on_all_keys_missing() -> Result<()> {
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let bob_chat_id = bob
.create_group_with_members(ProtectionStatus::Unprotected, "", &[alice, fiona])
.await;
let bob_chat_id = bob.create_group_with_members("", &[alice, fiona]).await;
bob.send_text(bob_chat_id, "Gossiping Fiona's key").await;
alice
.recv_msg(&bob.send_text(bob_chat_id, "No key gossip").await)
@@ -3197,8 +3180,8 @@ async fn test_out_failed_on_all_keys_missing() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_chat_media() -> Result<()> {
let t = TestContext::new_alice().await;
let chat_id1 = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let chat_id2 = create_group_chat(&t, ProtectionStatus::Unprotected, "bar").await?;
let chat_id1 = create_group(&t, "foo").await?;
let chat_id2 = create_group(&t, "bar").await?;
assert_eq!(
get_chat_media(
@@ -3422,7 +3405,7 @@ async fn test_get_chat_media_webxdc_order() -> Result<()> {
async fn test_blob_renaming() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
let chat_id = create_group(&alice, "Group").await?;
add_contact_to_chat(&alice, chat_id, alice.add_or_lookup_contact_id(&bob).await).await?;
let file = alice.get_blobdir().join("harmless_file.\u{202e}txt.exe");
fs::write(&file, "aaa").await?;
@@ -3484,9 +3467,7 @@ async fn test_sync_blocked() -> Result<()> {
// - Group chats synchronisation.
// - That blocking a group deletes it on other devices.
let fiona = TestContext::new_fiona().await;
let fiona_grp_chat_id = fiona
.create_group_with_members(ProtectionStatus::Unprotected, "grp", &[alice0])
.await;
let fiona_grp_chat_id = fiona.create_group_with_members("grp", &[alice0]).await;
let sent_msg = fiona.send_text(fiona_grp_chat_id, "hi").await;
let a0_grp_chat_id = alice0.recv_msg(&sent_msg).await.chat_id;
let a1_grp_chat_id = alice1.recv_msg(&sent_msg).await.chat_id;
@@ -3619,9 +3600,7 @@ async fn test_sync_delete_chat() -> Result<()> {
.get_matching(|evt| matches!(evt, EventType::ChatDeleted { .. }))
.await;
let bob_grp_chat_id = bob
.create_group_with_members(ProtectionStatus::Unprotected, "grp", &[alice0])
.await;
let bob_grp_chat_id = bob.create_group_with_members("grp", &[alice0]).await;
let sent_msg = bob.send_text(bob_grp_chat_id, "hi").await;
let a0_grp_chat_id = alice0.recv_msg(&sent_msg).await.chat_id;
let a1_grp_chat_id = alice1.recv_msg(&sent_msg).await.chat_id;
@@ -3854,6 +3833,61 @@ async fn test_sync_name() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync_create_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice0 = &tcm.alice().await;
let alice1 = &tcm.alice().await;
for a in [alice0, alice1] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
let bob = &tcm.bob().await;
let a0_bob_contact_id = alice0.add_or_lookup_contact_id(bob).await;
let a1_bob_contact_id = alice1.add_or_lookup_contact_id(bob).await;
let a0_chat_id = create_group(alice0, "grp").await?;
sync(alice0, alice1).await;
let a0_chat = Chat::load_from_db(alice0, a0_chat_id).await?;
let a1_chat_id = get_chat_id_by_grpid(alice1, &a0_chat.grpid)
.await?
.unwrap()
.0;
let a1_chat = Chat::load_from_db(alice1, a1_chat_id).await?;
assert_eq!(a1_chat.get_type(), Chattype::Group);
assert_eq!(a1_chat.is_promoted(), false);
assert_eq!(a1_chat.get_name(), "grp");
set_chat_name(alice0, a0_chat_id, "renamed").await?;
sync(alice0, alice1).await;
let a1_chat = Chat::load_from_db(alice1, a1_chat_id).await?;
assert_eq!(a1_chat.is_promoted(), false);
assert_eq!(a1_chat.get_name(), "renamed");
add_contact_to_chat(alice0, a0_chat_id, a0_bob_contact_id).await?;
sync(alice0, alice1).await;
let a1_chat = Chat::load_from_db(alice1, a1_chat_id).await?;
assert_eq!(a1_chat.is_promoted(), false);
assert_eq!(
get_chat_contacts(alice1, a1_chat_id).await?,
[a1_bob_contact_id, ContactId::SELF]
);
// Let's test a contact removal from another device.
remove_contact_from_chat(alice1, a1_chat_id, a1_bob_contact_id).await?;
sync(alice1, alice0).await;
let a0_chat = Chat::load_from_db(alice0, a0_chat_id).await?;
assert_eq!(a0_chat.is_promoted(), false);
assert_eq!(
get_chat_contacts(alice0, a0_chat_id).await?,
[ContactId::SELF]
);
let sent_msg = alice0.send_text(a0_chat_id, "hi").await;
let msg = alice1.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, a1_chat_id);
assert_eq!(a1_chat_id.is_promoted(alice1).await?, true);
Ok(())
}
/// Tests sending JPEG image with .png extension.
///
/// This is a regression test, previously sending failed
@@ -3988,9 +4022,7 @@ async fn test_info_contact_id() -> Result<()> {
}
// Alice creates group, Bob receives group
let alice_chat_id = alice
.create_group_with_members(ProtectionStatus::Unprotected, "play", &[bob])
.await;
let alice_chat_id = alice.create_group_with_members("play", &[bob]).await;
let sent_msg1 = alice.send_text(alice_chat_id, "moin").await;
let msg = bob.recv_msg(&sent_msg1).await;
@@ -4036,26 +4068,27 @@ async fn test_info_contact_id() -> Result<()> {
)
.await?;
let fiona_id = alice.add_or_lookup_contact_id(&tcm.fiona().await).await; // contexts are in sync, fiona_id is same everywhere
add_contact_to_chat(alice, alice_chat_id, fiona_id).await?;
let alice_fiona_id = alice.add_or_lookup_contact_id(&tcm.fiona().await).await;
let bob_fiona_id = bob.add_or_lookup_contact_id(&tcm.fiona().await).await;
add_contact_to_chat(alice, alice_chat_id, alice_fiona_id).await?;
pop_recv_and_check(
alice,
alice2,
bob,
SystemMessage::MemberAddedToGroup,
fiona_id,
fiona_id,
alice_fiona_id,
bob_fiona_id,
)
.await?;
remove_contact_from_chat(alice, alice_chat_id, fiona_id).await?;
remove_contact_from_chat(alice, alice_chat_id, alice_fiona_id).await?;
pop_recv_and_check(
alice,
alice2,
bob,
SystemMessage::MemberRemovedFromGroup,
fiona_id,
fiona_id,
alice_fiona_id,
bob_fiona_id,
)
.await?;
@@ -4063,11 +4096,12 @@ async fn test_info_contact_id() -> Result<()> {
// We raw delete in db as Contact::delete() leaves a tombstone (which is great as the tap works longer then)
alice
.sql
.execute("DELETE FROM contacts WHERE id=?", (fiona_id,))
.execute("DELETE FROM contacts WHERE id=?", (alice_fiona_id,))
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_info_type(), SystemMessage::MemberRemovedFromGroup);
assert!(msg.get_info_contact_id(alice).await?.is_none());
assert!(msg.get_info_contact_id(bob).await?.is_none());
Ok(())
}
@@ -4085,8 +4119,7 @@ async fn test_add_member_bug() -> Result<()> {
let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await;
// Create a group.
let alice_chat_id =
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(alice, "Group chat").await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
@@ -4130,8 +4163,7 @@ async fn test_past_members() -> Result<()> {
let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await;
tcm.section("Alice creates a chat.");
let alice_chat_id =
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(alice, "Group chat").await?;
add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
alice
.send_text(alice_chat_id, "Hi! I created a group.")
@@ -4165,8 +4197,7 @@ async fn test_non_member_cannot_modify_member_list() -> Result<()> {
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
let alice_chat_id =
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(alice, "Group chat").await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
let alice_sent_msg = alice
.send_text(alice_chat_id, "Hi! I created a group.")
@@ -4209,8 +4240,7 @@ async fn unpromoted_group_no_tombstones() -> Result<()> {
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await;
let alice_chat_id =
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(alice, "Group chat").await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 3);
@@ -4241,8 +4271,7 @@ async fn test_expire_past_members_after_60_days() -> Result<()> {
let fiona = &tcm.fiona().await;
let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await;
let alice_chat_id =
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(alice, "Group chat").await?;
add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?;
alice
.send_text(alice_chat_id, "Hi! I created a group.")
@@ -4279,7 +4308,7 @@ async fn test_past_members_order() -> Result<()> {
let fiona = tcm.fiona().await;
let fiona_contact_id = t.add_or_lookup_contact_id(&fiona).await;
let chat_id = create_group_chat(t, ProtectionStatus::Unprotected, "Group chat").await?;
let chat_id = create_group(t, "Group chat").await?;
add_contact_to_chat(t, chat_id, bob_contact_id).await?;
add_contact_to_chat(t, chat_id, charlie_contact_id).await?;
add_contact_to_chat(t, chat_id, fiona_contact_id).await?;
@@ -4341,8 +4370,7 @@ async fn test_restore_backup_after_60_days() -> Result<()> {
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
let alice_charlie_contact_id = alice.add_or_lookup_contact_id(charlie).await;
let alice_chat_id =
create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
let alice_chat_id = create_group(alice, "Group chat").await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
add_contact_to_chat(alice, alice_chat_id, alice_charlie_contact_id).await?;
@@ -4530,9 +4558,7 @@ async fn test_cannot_send_edit_request() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[bob])
.await;
let chat_id = alice.create_group_with_members("My Group", &[bob]).await;
// Alice can edit her message
let sent1 = alice.send_text(chat_id, "foo").await;
@@ -4703,7 +4729,7 @@ async fn test_no_address_contacts_in_group_chats() -> Result<()> {
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?;
let chat_id = create_group(alice, "Group chat").await?;
let bob_key_contact_id = alice.add_or_lookup_contact_id(bob).await;
let charlie_address_contact_id = alice.add_or_lookup_address_contact_id(charlie).await;
@@ -4762,7 +4788,7 @@ async fn test_create_unencrypted_group_chat() -> Result<()> {
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let chat_id = create_group_ex(alice, None, "Group chat").await?;
let chat_id = create_group_unencrypted(alice, "Group chat").await?;
let bob_key_contact_id = alice.add_or_lookup_contact_id(bob).await;
let charlie_address_contact_id = alice.add_or_lookup_address_contact_id(charlie).await;
@@ -4783,7 +4809,7 @@ async fn test_create_unencrypted_group_chat() -> Result<()> {
async fn test_create_group_invalid_name() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = create_group_ex(alice, None, " ").await?;
let chat_id = create_group(alice, " ").await?;
let chat = Chat::load_from_db(alice, chat_id).await?;
assert_eq!(chat.get_name(), "");
Ok(())
@@ -4829,7 +4855,7 @@ async fn test_long_group_name() -> Result<()> {
let bob = &tcm.bob().await;
let group_name = "δδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδ";
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, group_name).await?;
let alice_chat_id = create_group(alice, group_name).await?;
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
let sent = alice

View File

@@ -481,8 +481,8 @@ mod tests {
use super::*;
use crate::chat::save_msgs;
use crate::chat::{
ProtectionStatus, add_contact_to_chat, create_group_chat, get_chat_contacts,
remove_contact_from_chat, send_text_msg,
add_contact_to_chat, create_group, get_chat_contacts, remove_contact_from_chat,
send_text_msg,
};
use crate::receive_imf::receive_imf;
use crate::stock_str::StockMessage;
@@ -495,15 +495,9 @@ mod tests {
async fn test_try_load() {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let chat_id1 = create_group_chat(bob, ProtectionStatus::Unprotected, "a chat")
.await
.unwrap();
let chat_id2 = create_group_chat(bob, ProtectionStatus::Unprotected, "b chat")
.await
.unwrap();
let chat_id3 = create_group_chat(bob, ProtectionStatus::Unprotected, "c chat")
.await
.unwrap();
let chat_id1 = create_group(bob, "a chat").await.unwrap();
let chat_id2 = create_group(bob, "b chat").await.unwrap();
let chat_id3 = create_group(bob, "c chat").await.unwrap();
// check that the chatlist starts with the most recent message
let chats = Chatlist::try_load(bob, 0, None, None).await.unwrap();
@@ -536,9 +530,7 @@ mod tests {
// receive a message from alice
let alice = &tcm.alice().await;
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "alice chat")
.await
.unwrap();
let alice_chat_id = create_group(alice, "alice chat").await.unwrap();
add_contact_to_chat(
alice,
alice_chat_id,
@@ -576,9 +568,7 @@ mod tests {
async fn test_sort_self_talk_up_on_forward() {
let t = TestContext::new_alice().await;
t.update_device_chats().await.unwrap();
create_group_chat(&t, ProtectionStatus::Unprotected, "a chat")
.await
.unwrap();
create_group(&t, "a chat").await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 3);
@@ -765,9 +755,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_summary_unwrap() {
let t = TestContext::new().await;
let chat_id1 = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat")
.await
.unwrap();
let chat_id1 = create_group(&t, "a chat").await.unwrap();
let mut msg = Message::new_text("foo:\nbar \r\n test".to_string());
chat_id1.set_draft(&t, Some(&mut msg)).await.unwrap();
@@ -783,9 +771,7 @@ mod tests {
async fn test_get_summary_deleted_draft() {
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat")
.await
.unwrap();
let chat_id = create_group(&t, "a chat").await.unwrap();
let mut msg = Message::new_text("Foobar".to_string());
chat_id.set_draft(&t, Some(&mut msg)).await.unwrap();
@@ -824,15 +810,9 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_broken() {
let t = TestContext::new_bob().await;
let chat_id1 = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat")
.await
.unwrap();
create_group_chat(&t, ProtectionStatus::Unprotected, "b chat")
.await
.unwrap();
create_group_chat(&t, ProtectionStatus::Unprotected, "c chat")
.await
.unwrap();
let chat_id1 = create_group(&t, "a chat").await.unwrap();
create_group(&t, "b chat").await.unwrap();
create_group(&t, "c chat").await.unwrap();
// check that the chatlist starts with the most recent message
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();

View File

@@ -28,7 +28,7 @@ fn rgb_to_u32(rgb: Rgb<u8>) -> u32 {
/// Lightness is set to half (0.5) to make colors suitable both for light and dark theme.
pub fn str_to_color(s: &str) -> u32 {
let lightness = 0.5;
let chroma = 0.22;
let chroma = 0.23;
let angle = str_to_angle(s);
let oklch = Oklch::new(lightness, chroma, angle);
let rgb = oklch.to_rgb(TransferFunction::Srgb);

View File

@@ -14,7 +14,6 @@ use tokio::fs;
use crate::blob::BlobObject;
use crate::configure::EnteredLoginParam;
use crate::constants;
use crate::context::Context;
use crate::events::EventType;
use crate::log::{LogExt, info};
@@ -23,6 +22,7 @@ use crate::mimefactory::RECOMMENDED_FILE_SIZE;
use crate::provider::{Provider, get_provider_by_id};
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::get_abs_path;
use crate::{constants, stats};
/// The available configuration keys.
#[derive(
@@ -389,12 +389,6 @@ pub enum Config {
/// Make all outgoing messages with Autocrypt header "multipart/signed".
SignUnencrypted,
/// Enable header protection for `Autocrypt` header.
///
/// This is an experimental setting not compatible to other MUAs
/// and older Delta Chat versions (core version <= v1.149.0).
ProtectAutocrypt,
/// Let the core save all events to the database.
/// This value is used internally to remember the MsgId of the logging xdc
#[strum(props(default = "0"))]
@@ -414,9 +408,22 @@ pub enum Config {
/// used for signatures, encryption to self and included in `Autocrypt` header.
KeyId,
/// This key is sent to the self_reporting bot so that the bot can recognize the user
/// Send statistics to Delta Chat's developers.
/// Can be exposed to the user as a setting.
StatsSending,
/// Last time statistics were sent to Delta Chat's developers
StatsLastSent,
/// Last time `update_message_stats()` was called
StatsLastUpdate,
/// This key is sent to the statistics bot so that the bot can recognize the user
/// without storing the email address
SelfReportingId,
StatsId,
/// The last contact id that already existed when statistics-sending was enabled for the first time.
StatsLastOldContactId,
/// MsgId of webxdc map integration.
WebxdcIntegration,
@@ -582,8 +589,9 @@ impl Context {
/// Returns boolean configuration value for the given key.
pub async fn get_config_bool(&self, key: Config) -> Result<bool> {
Ok(self
.get_config_parsed::<i32>(key)
.get_config(key)
.await?
.and_then(|s| s.parse::<i32>().ok())
.map(|x| x != 0)
.unwrap_or_default())
}
@@ -722,9 +730,15 @@ impl Context {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
};
if key == Config::StatsSending {
let old_value = self.get_config(key).await?;
let old_value = bool_from_config(old_value.as_deref());
let new_value = bool_from_config(value);
stats::pre_sending_config_change(self, old_value, new_value).await?;
}
self.set_config_internal(key, value).await?;
if key == Config::SentboxWatch {
self.last_full_folder_scan.lock().await.take();
if key == Config::StatsSending {
stats::maybe_send_stats(self).await?;
}
Ok(())
}
@@ -744,6 +758,16 @@ impl Context {
let better_value;
match key {
Config::Selfstatus => {
// Currently we send the self-status in every appropriate message, but in the future
// (when most users upgrade to "feat: Don't reset key-contact status if
// Chat-User-Avatar header is absent") we want to send it periodically together with
// the self-avatar. This ensures the correct behavior after a possible Core upgrade.
self.sql
.execute("UPDATE contacts SET selfavatar_sent=0", ())
.await?;
self.sql.set_raw_config(key.as_ref(), value).await?;
}
Config::Selfavatar => {
self.sql
.execute("UPDATE contacts SET selfavatar_sent=0;", ())
@@ -877,6 +901,10 @@ pub(crate) fn from_bool(val: bool) -> Option<&'static str> {
Some(if val { "1" } else { "0" })
}
pub(crate) fn bool_from_config(config: Option<&str>) -> bool {
config.is_some_and(|v| v.parse::<i32>().unwrap_or_default() != 0)
}
// Separate impl block for self address handling
impl Context {
/// Determine whether the specified addr maps to the/a self addr.

View File

@@ -278,7 +278,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 +287,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 +296,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 +327,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

@@ -36,6 +36,7 @@ use crate::login_param::{
use crate::message::Message;
use crate::oauth2::get_oauth2_addr;
use crate::provider::{Protocol, Provider, Socket, UsernamePattern};
use crate::qr::{login_param_from_account_qr, login_param_from_login_qr};
use crate::smtp::Smtp;
use crate::sync::Sync::*;
use crate::tools::time;
@@ -117,7 +118,7 @@ impl Context {
Ok(())
}
async fn add_transport_inner(&self, param: &mut EnteredLoginParam) -> Result<()> {
pub(crate) async fn add_transport_inner(&self, param: &mut EnteredLoginParam) -> Result<()> {
ensure!(
!self.scheduler.is_running().await,
"cannot configure, already running"
@@ -162,20 +163,15 @@ impl Context {
pub async fn add_transport_from_qr(&self, qr: &str) -> Result<()> {
self.stop_io().await;
// This code first sets the deprecated Config::Addr, Config::MailPw, etc.
// and then calls configure(), which loads them again.
// At some point, we will remove configure()
// and then simplify the code
// to directly create an EnteredLoginParam.
let result = async move {
match crate::qr::check_qr(self, qr).await? {
crate::qr::Qr::Account { .. } => crate::qr::set_account_from_qr(self, qr).await?,
let mut param = match crate::qr::check_qr(self, qr).await? {
crate::qr::Qr::Account { .. } => login_param_from_account_qr(self, qr).await?,
crate::qr::Qr::Login { address, options } => {
crate::qr::configure_from_login_qr(self, &address, options).await?
login_param_from_login_qr(&address, options)?
}
_ => bail!("QR code does not contain account"),
}
self.configure().await?;
};
self.add_transport_inner(&mut param).await?;
Ok(())
}
.await;
@@ -300,8 +296,6 @@ async fn get_configured_param(
param.smtp.password.clone()
};
let proxy_enabled = ctx.get_config_bool(Config::ProxyEnabled).await?;
let mut addr = param.addr.clone();
if param.oauth2 {
// the used oauth2 addr may differ, check this.
@@ -343,7 +337,7 @@ async fn get_configured_param(
"checking internal provider-info for offline autoconfig"
);
provider = provider::get_provider_info(ctx, &param_domain, proxy_enabled).await;
provider = provider::get_provider_info(&param_domain);
if let Some(provider) = provider {
if provider.server.is_empty() {
info!(ctx, "Offline autoconfig found, but no servers defined.");

View File

@@ -106,7 +106,7 @@ fn parse_server<B: BufRead>(
}
}
Event::Text(ref event) => {
let val = event.unescape().unwrap_or_default().trim().to_owned();
let val = event.xml_content().unwrap_or_default().trim().to_owned();
match tag_config {
MozConfigTag::Hostname => hostname = Some(val),

View File

@@ -79,7 +79,7 @@ fn parse_protocol<B: BufRead>(
}
}
Event::Text(ref e) => {
let val = e.unescape().unwrap_or_default();
let val = e.xml_content().unwrap_or_default();
if let Some(ref tag) = current_tag {
match tag.as_str() {
@@ -123,7 +123,7 @@ fn parse_redirecturl<B: BufRead>(
let mut buf = Vec::new();
match reader.read_event_into(&mut buf)? {
Event::Text(ref e) => {
let val = e.unescape().unwrap_or_default();
let val = e.xml_content().unwrap_or_default();
Ok(val.trim().to_string())
}
_ => Ok("".to_string()),

View File

@@ -101,6 +101,8 @@ pub const DC_CHAT_ID_LAST_SPECIAL: ChatId = ChatId::new(9);
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
FromPrimitive,
ToPrimitive,
FromSql,
@@ -118,7 +120,7 @@ pub enum Chattype {
/// Group chat.
///
/// Created by [`crate::chat::create_group_chat`].
/// Created by [`crate::chat::create_group`].
Group = 120,
/// An (unencrypted) mailing list,

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};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, 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.
@@ -383,11 +383,7 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
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
{
if 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
@@ -395,7 +391,7 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
}
}
if let Some(biography) = &contact.biography {
if let Err(e) = set_status(context, id, biography.to_owned(), false, false).await {
if let Err(e) = set_status(context, id, biography.to_owned()).await {
warn!(
context,
"import_vcard_contact: Could not set biography for {}: {e:#}.", contact.addr
@@ -1282,14 +1278,10 @@ impl Contact {
let list = context
.sql
.query_map(
.query_map_vec(
"SELECT id FROM contacts WHERE id>? AND blocked!=0 ORDER BY last_seen DESC, id DESC;",
(ContactId::LAST_SPECIAL,),
|row| row.get::<_, ContactId>(0),
|ids| {
ids.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await?;
Ok(list)
@@ -1574,17 +1566,10 @@ impl Contact {
Ok(None)
}
/// Get a color for the contact.
/// The color is calculated from the contact's fingerprint (for key-contacts)
/// or email address (for address-contacts) and can be used
/// for an fallback avatar with white initials
/// as well as for headlines in bubbles of group chats.
/// Returns a color for the contact.
/// See [`self::get_color`].
pub fn get_color(&self) -> u32 {
if let Some(fingerprint) = self.fingerprint() {
str_to_color(&fingerprint.hex())
} else {
str_to_color(&self.addr.to_lowercase())
}
get_color(self.id == ContactId::SELF, &self.addr, &self.fingerprint())
}
/// Gets the contact's status.
@@ -1680,6 +1665,21 @@ impl Contact {
}
}
/// Returns a color for a contact having given attributes.
///
/// The color is calculated from contact's fingerprint (for key-contacts) or email address (for
/// address-contacts; should be lowercased to avoid allocation) and can be used for an fallback
/// avatar with white initials as well as for headlines in bubbles of group chats.
pub fn get_color(is_self: bool, addr: &str, fingerprint: &Option<Fingerprint>) -> u32 {
if let Some(fingerprint) = fingerprint {
str_to_color(&fingerprint.hex())
} else if is_self {
0x808080
} else {
str_to_color(&to_lowercase(addr))
}
}
// Updates the names of the chats which use the contact name.
//
// This is one of the few duplicated data, however, getting the chat list is easier this way.
@@ -1742,8 +1742,7 @@ pub(crate) async fn set_blocked(
) -> Result<()> {
ensure!(
!contact_id.is_special(),
"Can't block special contact {}",
contact_id
"Can't block special contact {contact_id}"
);
let contact = Contact::get_by_id(context, contact_id).await?;
@@ -1783,9 +1782,7 @@ 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?
{
if let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await? {
chat_id.unblock_ex(context, Nosync).await?;
}
}
@@ -1817,25 +1814,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);
}
@@ -1843,13 +1834,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);
}
@@ -1866,22 +1853,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

@@ -1,10 +1,11 @@
use deltachat_contact_tools::{addr_cmp, may_be_valid_addr};
use super::*;
use crate::chat::{Chat, ProtectionStatus, get_chat_contacts, send_text_msg};
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync};
#[test]
fn test_contact_id_values() {
@@ -759,7 +760,7 @@ async fn test_contact_get_color() -> Result<()> {
let t = TestContext::new().await;
let contact_id = Contact::create(&t, "name", "name@example.net").await?;
let color1 = Contact::get_by_id(&t, contact_id).await?.get_color();
assert_eq!(color1, 0x4947dc);
assert_eq!(color1, 0x4844e2);
let t = TestContext::new().await;
let contact_id = Contact::create(&t, "prename name", "name@example.net").await?;
@@ -773,6 +774,20 @@ async fn test_contact_get_color() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_self_color_vs_key() -> 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();
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);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_get_encrinfo() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -831,8 +846,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();
@@ -851,21 +865,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.
@@ -873,17 +878,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(())
}
@@ -896,9 +893,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);
@@ -914,17 +911,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());
@@ -1305,9 +1292,6 @@ async fn test_self_is_verified() -> Result<()> {
assert!(contact.get_verifier_id(&alice).await?.is_none());
assert!(contact.is_key_contact());
let chat_id = ChatId::get_for_contact(&alice, ContactId::SELF).await?;
assert!(chat_id.is_protected(&alice).await.unwrap() == ProtectionStatus::Protected);
Ok(())
}

View File

@@ -10,27 +10,22 @@ use std::time::Duration;
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self as channel, Receiver, Sender};
use pgp::types::PublicKeyTrait;
use ratelimit::Ratelimit;
use tokio::sync::{Mutex, Notify, RwLock};
use crate::chat::{ChatId, ProtectionStatus, get_chat_cnt};
use crate::chatlist_events;
use crate::chat::{ChatId, get_chat_cnt};
use crate::config::Config;
use crate::constants::{
self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_CHAT_ID_TRASH, DC_VERSION_STR,
};
use crate::contact::{Contact, ContactId, import_vcard, mark_contact_id_as_verified};
use crate::constants::{self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_VERSION_STR};
use crate::contact::{Contact, ContactId};
use crate::debug_logging::DebugLogging;
use crate::download::DownloadState;
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::imap::{FolderMeaning, Imap, ServerMetadata};
use crate::key::{load_self_secret_key, self_fingerprint};
use crate::key::self_fingerprint;
use crate::log::{info, warn};
use crate::logged_debug_assert;
use crate::login_param::{ConfiguredLoginParam, EnteredLoginParam};
use crate::message::{self, Message, MessageState, MsgId};
use crate::param::{Param, Params};
use crate::message::{self, MessageState, MsgId};
use crate::net::tls::TlsSessionStore;
use crate::peer_channels::Iroh;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo;
@@ -38,7 +33,8 @@ use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning
use crate::sql::Sql;
use crate::stock_str::StockStrings;
use crate::timesmearing::SmearedTimestamp;
use crate::tools::{self, create_id, duration_to_str, time, time_elapsed};
use crate::tools::{self, duration_to_str, time, time_elapsed};
use crate::{chatlist_events, stats};
/// Builder for the [`Context`].
///
@@ -262,8 +258,6 @@ pub struct InnerContext {
/// IMAP METADATA.
pub(crate) metadata: RwLock<Option<ServerMetadata>>,
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
/// ID for this `Context` in the current process.
///
/// This allows for multiple `Context`s open in a single process where each context can
@@ -297,6 +291,9 @@ pub struct InnerContext {
/// True if account has subscribed to push notifications via IMAP.
pub(crate) push_subscribed: AtomicBool,
/// TLS session resumption cache.
pub(crate) tls_session_store: TlsSessionStore,
/// Iroh for realtime peer channels.
pub(crate) iroh: Arc<RwLock<Option<Iroh>>>,
@@ -469,12 +466,12 @@ impl Context {
server_id: RwLock::new(None),
metadata: RwLock::new(None),
creation_time: tools::Time::now(),
last_full_folder_scan: Mutex::new(None),
last_error: parking_lot::RwLock::new("".to_string()),
migration_error: parking_lot::RwLock::new(None),
debug_logging: std::sync::RwLock::new(None),
push_subscriber,
push_subscribed: AtomicBool::new(false),
tls_session_store: TlsSessionStore::new(),
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
connectivities: parking_lot::Mutex::new(Vec::new()),
@@ -821,7 +818,6 @@ impl Context {
let unblocked_msgs = message::get_unblocked_msg_cnt(self).await;
let request_msgs = message::get_request_msg_cnt(self).await;
let contacts = Contact::get_real_cnt(self).await?;
let is_configured = self.get_config_int(Config::Configured).await?;
let proxy_enabled = self.get_config_int(Config::ProxyEnabled).await?;
let dbversion = self
.sql
@@ -900,7 +896,6 @@ impl Context {
.await?
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert("is_configured", is_configured.to_string());
res.insert("proxy_enabled", proxy_enabled.to_string());
res.insert("entered_account_settings", l.to_string());
res.insert("used_account_settings", l2);
@@ -1030,12 +1025,6 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"protect_autocrypt",
self.get_config_int(Config::ProtectAutocrypt)
.await?
.to_string(),
);
res.insert(
"debug_logging",
self.get_config_int(Config::DebugLogging).await?.to_string(),
@@ -1067,6 +1056,22 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"stats_id",
self.get_config(Config::StatsId)
.await?
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert(
"stats_sending",
stats::should_send_stats(self).await?.to_string(),
);
res.insert(
"stats_last_sent",
self.get_config_i64(Config::StatsLastSent)
.await?
.to_string(),
);
res.insert(
"fail_on_receiving_full_msg",
self.sql
@@ -1081,147 +1086,6 @@ impl Context {
Ok(res)
}
async fn get_self_report(&self) -> Result<String> {
#[derive(Default)]
struct ChatNumbers {
protected: u32,
opportunistic_dc: u32,
opportunistic_mua: u32,
unencrypted_dc: u32,
unencrypted_mua: u32,
}
let mut res = String::new();
res += &format!("core_version {}\n", get_version_str());
let num_msgs: u32 = self
.sql
.query_get_value(
"SELECT COUNT(*) FROM msgs WHERE hidden=0 AND chat_id!=?",
(DC_CHAT_ID_TRASH,),
)
.await?
.unwrap_or_default();
res += &format!("num_msgs {num_msgs}\n");
let num_chats: u32 = self
.sql
.query_get_value("SELECT COUNT(*) FROM chats WHERE id>9 AND blocked!=1", ())
.await?
.unwrap_or_default();
res += &format!("num_chats {num_chats}\n");
let db_size = tokio::fs::metadata(&self.sql.dbfile).await?.len();
res += &format!("db_size_bytes {db_size}\n");
let secret_key = &load_self_secret_key(self).await?.primary_key;
let key_created = secret_key.public_key().created_at().timestamp();
res += &format!("key_created {key_created}\n");
// how many of the chats active in the last months are:
// - protected
// - opportunistic-encrypted and the contact uses Delta Chat
// - opportunistic-encrypted and the contact uses a classical MUA
// - unencrypted and the contact uses Delta Chat
// - unencrypted and the contact uses a classical MUA
let three_months_ago = time().saturating_sub(3600 * 24 * 30 * 3);
let chats = self
.sql
.query_map(
"SELECT c.protected, m.param, m.msgrmsg
FROM chats c
JOIN msgs m
ON c.id=m.chat_id
AND m.id=(
SELECT id
FROM msgs
WHERE chat_id=c.id
AND hidden=0
AND download_state=?
AND to_id!=?
ORDER BY timestamp DESC, id DESC LIMIT 1)
WHERE c.id>9
AND (c.blocked=0 OR c.blocked=2)
AND IFNULL(m.timestamp,c.created_timestamp) > ?
GROUP BY c.id",
(DownloadState::Done, ContactId::INFO, three_months_ago),
|row| {
let protected: ProtectionStatus = row.get(0)?;
let message_param: Params =
row.get::<_, String>(1)?.parse().unwrap_or_default();
let is_dc_message: bool = row.get(2)?;
Ok((protected, message_param, is_dc_message))
},
|rows| {
let mut chats = ChatNumbers::default();
for row in rows {
let (protected, message_param, is_dc_message) = row?;
let encrypted = message_param
.get_bool(Param::GuaranteeE2ee)
.unwrap_or(false);
if protected == ProtectionStatus::Protected {
chats.protected += 1;
} else if encrypted {
if is_dc_message {
chats.opportunistic_dc += 1;
} else {
chats.opportunistic_mua += 1;
}
} else if is_dc_message {
chats.unencrypted_dc += 1;
} else {
chats.unencrypted_mua += 1;
}
}
Ok(chats)
},
)
.await?;
res += &format!("chats_protected {}\n", chats.protected);
res += &format!("chats_opportunistic_dc {}\n", chats.opportunistic_dc);
res += &format!("chats_opportunistic_mua {}\n", chats.opportunistic_mua);
res += &format!("chats_unencrypted_dc {}\n", chats.unencrypted_dc);
res += &format!("chats_unencrypted_mua {}\n", chats.unencrypted_mua);
let self_reporting_id = match self.get_config(Config::SelfReportingId).await? {
Some(id) => id,
None => {
let id = create_id();
self.set_config(Config::SelfReportingId, Some(&id)).await?;
id
}
};
res += &format!("self_reporting_id {self_reporting_id}");
Ok(res)
}
/// Drafts a message with statistics about the usage of Delta Chat.
/// The user can inspect the message if they want, and then hit "Send".
///
/// On the other end, a bot will receive the message and make it available
/// to Delta Chat's developers.
pub async fn draft_self_report(&self) -> Result<ChatId> {
const SELF_REPORTING_BOT_VCARD: &str = include_str!("../assets/self-reporting-bot.vcf");
let contact_id: ContactId = *import_vcard(self, SELF_REPORTING_BOT_VCARD)
.await?
.first()
.context("Self reporting bot vCard does not contain a contact")?;
mark_contact_id_as_verified(self, contact_id, Some(ContactId::SELF)).await?;
let chat_id = ChatId::create_for_contact(self, contact_id).await?;
chat_id
.set_protection(self, ProtectionStatus::Protected, time(), Some(contact_id))
.await?;
let mut msg = Message::new_text(self.get_self_report().await?);
chat_id.set_draft(self, Some(&mut msg)).await?;
Ok(chat_id)
}
/// Get a list of fresh, unmuted messages in unblocked chats.
///
/// The list starts with the most recent message
@@ -1231,7 +1095,7 @@ impl Context {
pub async fn get_fresh_msgs(&self) -> Result<Vec<MsgId>> {
let list = self
.sql
.query_map(
.query_map_vec(
concat!(
"SELECT m.id",
" FROM msgs m",
@@ -1249,13 +1113,6 @@ impl Context {
),
(MessageState::InFresh, time()),
|row| row.get::<_, MsgId>(0),
|rows| {
let mut list = Vec::new();
for row in rows {
list.push(row?);
}
Ok(list)
},
)
.await?;
Ok(list)
@@ -1288,7 +1145,7 @@ impl Context {
let list = self
.sql
.query_map(
.query_map_vec(
"SELECT m.id
FROM msgs m
LEFT JOIN contacts ct
@@ -1308,13 +1165,6 @@ impl Context {
let msg_id: MsgId = row.get(0)?;
Ok(msg_id)
},
|rows| {
let mut list = Vec::new();
for row in rows {
list.push(row?);
}
Ok(list)
},
)
.await?;
Ok(list)
@@ -1355,7 +1205,7 @@ impl Context {
let list = if let Some(chat_id) = chat_id {
self.sql
.query_map(
.query_map_vec(
"SELECT m.id AS id
FROM msgs m
LEFT JOIN contacts ct
@@ -1367,13 +1217,6 @@ impl Context {
ORDER BY m.timestamp,m.id;",
(chat_id, str_like_in_text),
|row| row.get::<_, MsgId>("id"),
|rows| {
let mut ret = Vec::new();
for id in rows {
ret.push(id?);
}
Ok(ret)
},
)
.await?
} else {
@@ -1388,7 +1231,7 @@ impl Context {
// According to some tests, this limit speeds up eg. 2 character searches by factor 10.
// The limit is documented and UI may add a hint when getting 1000 results.
self.sql
.query_map(
.query_map_vec(
"SELECT m.id AS id
FROM msgs m
LEFT JOIN contacts ct
@@ -1403,13 +1246,6 @@ impl Context {
ORDER BY m.id DESC LIMIT 1000",
(str_like_in_text,),
|row| row.get::<_, MsgId>("id"),
|rows| {
let mut ret = Vec::new();
for id in rows {
ret.push(id?);
}
Ok(ret)
},
)
.await?
};

View File

@@ -6,9 +6,9 @@ use super::*;
use crate::chat::{Chat, MuteDuration, get_chat_contacts, get_chat_msgs, send_msg, set_muted};
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::mimeparser::SystemMessage;
use crate::message::Message;
use crate::receive_imf::receive_imf;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, get_chat_msg};
use crate::test_utils::{E2EE_INFO_MSGS, TestContext};
use crate::tools::{SystemTime, create_outgoing_rfc724_mid};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -276,7 +276,6 @@ async fn test_get_info_completeness() {
"mail_port",
"mail_security",
"notify_about_wrong_pw",
"self_reporting_id",
"selfstatus",
"send_server",
"send_user",
@@ -296,6 +295,8 @@ async fn test_get_info_completeness() {
"webxdc_integration",
"device_token",
"encrypted_device_token",
"stats_last_update",
"stats_last_old_contact_id",
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();
@@ -598,26 +599,6 @@ async fn test_get_next_msgs() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_draft_self_report() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id = alice.draft_self_report().await?;
let msg = get_chat_msg(&alice, chat_id, 0, 1).await;
assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled);
let chat = Chat::load_from_db(&alice, chat_id).await?;
assert!(chat.is_protected());
let mut draft = chat_id.get_draft(&alice).await?.unwrap();
assert!(draft.text.starts_with("core_version"));
// Test that sending into the protected chat works:
let _sent = alice.send_msg(chat_id, &mut draft).await;
Ok(())
}
#[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;

View File

@@ -7,6 +7,7 @@ use std::sync::LazyLock;
use quick_xml::{
Reader,
errors::Error as QuickXmlError,
events::{BytesEnd, BytesStart, BytesText},
};
@@ -132,6 +133,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
reader.config_mut().check_end_names = false;
let mut buf = Vec::new();
let mut char_buf = String::with_capacity(4);
loop {
match reader.read_event_into(&mut buf) {
@@ -140,16 +142,9 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
}
Ok(quick_xml::events::Event::End(ref e)) => dehtml_endtag_cb(e, &mut dehtml),
Ok(quick_xml::events::Event::Text(ref e)) => dehtml_text_cb(e, &mut dehtml),
Ok(quick_xml::events::Event::CData(e)) => match e.escape() {
Ok(e) => dehtml_text_cb(&e, &mut dehtml),
Err(e) => {
eprintln!(
"CDATA escape error at position {}: {:?}",
reader.buffer_position(),
e,
);
}
},
Ok(quick_xml::events::Event::CData(e)) => {
str_cb(&String::from_utf8_lossy(&e as &[_]), &mut dehtml)
}
Ok(quick_xml::events::Event::Empty(ref e)) => {
// Handle empty tags as a start tag immediately followed by end tag.
// For example, `<p/>` is treated as `<p></p>`.
@@ -159,6 +154,33 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
&mut dehtml,
);
}
Ok(quick_xml::events::Event::GeneralRef(ref e)) => {
match e.resolve_char_ref() {
Err(err) => eprintln!(
"resolve_char_ref() error at position {}: {:?}",
reader.buffer_position(),
err,
),
Ok(Some(ch)) => {
char_buf.clear();
char_buf.push(ch);
str_cb(&char_buf, &mut dehtml);
}
Ok(None) => {
let event_str = String::from_utf8_lossy(e);
if let Some(s) = quick_xml::escape::resolve_html5_entity(&event_str) {
str_cb(s, &mut dehtml);
} else {
// Nonstandard entity. Add escaped.
str_cb(&format!("&{event_str};"), &mut dehtml);
}
}
}
}
Err(QuickXmlError::IllFormed(_)) => {
// This is probably not HTML at all and should be left as is.
str_cb(&String::from_utf8_lossy(&buf), &mut dehtml);
}
Err(e) => {
eprintln!(
"Parse html error: Error at position {}: {:?}",
@@ -176,36 +198,36 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
}
fn dehtml_text_cb(event: &BytesText, dehtml: &mut Dehtml) {
static LINE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(\r?\n)+").unwrap());
if dehtml.get_add_text() == AddText::YesPreserveLineEnds
|| dehtml.get_add_text() == AddText::YesRemoveLineEnds
{
let event = event as &[_];
let event_str = std::str::from_utf8(event).unwrap_or_default();
let mut last_added = escaper::decode_html_buf_sloppy(event).unwrap_or_default();
if event_str.starts_with(&last_added) {
last_added = event_str.to_string();
str_cb(event_str, dehtml);
}
}
fn str_cb(event_str: &str, dehtml: &mut Dehtml) {
static LINE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(\r?\n)+").unwrap());
let add_text = dehtml.get_add_text();
if add_text == AddText::YesRemoveLineEnds {
// Replace all line ends with spaces.
// E.g. `\r\n\r\n` is replaced with one space.
let event_str = LINE_RE.replace_all(event_str, " ");
// Add a space if `event_str` starts with a space
// and there is no whitespace at the end of the buffer yet.
// Trim the rest of leading whitespace from `event_str`.
let buf = dehtml.get_buf();
if !buf.ends_with(' ') && !buf.ends_with('\n') && event_str.starts_with(' ') {
*buf += " ";
}
if dehtml.get_add_text() == AddText::YesRemoveLineEnds {
// Replace all line ends with spaces.
// E.g. `\r\n\r\n` is replaced with one space.
let last_added = LINE_RE.replace_all(&last_added, " ");
// Add a space if `last_added` starts with a space
// and there is no whitespace at the end of the buffer yet.
// Trim the rest of leading whitespace from `last_added`.
let buf = dehtml.get_buf();
if !buf.ends_with(' ') && !buf.ends_with('\n') && last_added.starts_with(' ') {
*buf += " ";
}
*buf += last_added.trim_start();
} else {
*dehtml.get_buf() += LINE_RE.replace_all(&last_added, "\n").as_ref();
}
*buf += event_str.trim_start();
} else if add_text == AddText::YesPreserveLineEnds {
*dehtml.get_buf() += LINE_RE.replace_all(event_str, "\n").as_ref();
}
}

View File

@@ -157,30 +157,23 @@ pub(crate) async fn download_msg(
let row = context
.sql
.query_row_optional(
"SELECT uid, folder, uidvalidity FROM imap WHERE rfc724_mid=? AND target!=''",
"SELECT uid, folder FROM imap WHERE rfc724_mid=? AND target!=''",
(&msg.rfc724_mid,),
|row| {
let server_uid: u32 = row.get(0)?;
let server_folder: String = row.get(1)?;
let uidvalidity: u32 = row.get(2)?;
Ok((server_uid, server_folder, uidvalidity))
Ok((server_uid, server_folder))
},
)
.await?;
let Some((server_uid, server_folder, uidvalidity)) = row else {
let Some((server_uid, server_folder)) = row else {
// No IMAP record found, we don't know the UID and folder.
return Err(anyhow!("Call download_full() again to try over."));
};
session
.fetch_single_msg(
context,
&server_folder,
uidvalidity,
server_uid,
msg.rfc724_mid.clone(),
)
.fetch_single_msg(context, &server_folder, server_uid, msg.rfc724_mid.clone())
.await?;
Ok(())
}
@@ -194,7 +187,6 @@ impl Session {
&mut self,
context: &Context,
folder: &str,
uidvalidity: u32,
uid: u32,
rfc724_mid: String,
) -> Result<()> {
@@ -214,16 +206,8 @@ impl Session {
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
uid_message_ids.insert(uid, rfc724_mid);
let (sender, receiver) = async_channel::unbounded();
self.fetch_many_msgs(
context,
folder,
uidvalidity,
vec![uid],
&uid_message_ids,
false,
sender,
)
.await?;
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, false, sender)
.await?;
if receiver.recv().await.is_err() {
bail!("Failed to fetch UID {uid}");
}
@@ -283,8 +267,9 @@ mod tests {
use super::*;
use crate::chat::{get_chat_msgs, send_msg};
use crate::ephemeral::Timer;
use crate::message::delete_msgs;
use crate::receive_imf::receive_imf_from_inbox;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext};
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
#[test]
fn test_downloadstate_values() {
@@ -543,4 +528,43 @@ mod tests {
Ok(())
}
/// Tests that fully downloading the message
/// works even if the Message-ID already exists
/// in the database assigned to the trash chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_download_trashed() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let imf_raw = b"From: Bob <bob@example.org>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: subject\n\
Message-ID: <first@example.org>\n\
Date: Sun, 14 Nov 2021 00:10:00 +0000\
Content-Type: text/plain";
// Download message from Bob partially.
let partial_received_msg =
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, Some(100000))
.await?
.unwrap();
assert_eq!(partial_received_msg.msg_ids.len(), 1);
// Delete the received message.
// Not it is still in the database,
// but in the trash chat.
delete_msgs(alice, &[partial_received_msg.msg_ids[0]]).await?;
// Fully download message after deletion.
let full_received_msg =
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, None).await?;
// The message does not reappear.
// However, `receive_imf` should not fail.
assert!(full_received_msg.is_none());
Ok(())
}
}

View File

@@ -80,12 +80,12 @@ use crate::contact::ContactId;
use crate::context::Context;
use crate::download::MIN_DELETE_SERVER_AFTER;
use crate::events::EventType;
use crate::location;
use crate::log::{LogExt, error, info, warn};
use crate::message::{Message, MessageState, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::stock_str;
use crate::tools::{SystemTime, duration_to_str, time};
use crate::{location, stats};
/// Ephemeral timer value.
#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)]
@@ -376,13 +376,17 @@ pub(crate) async fn start_chat_ephemeral_timers(context: &Context, chat_id: Chat
/// `delete_device_after` setting or `ephemeral_timestamp` column.
///
/// For each message a row ID, chat id, viewtype and location ID is returned.
///
/// Unknown viewtypes are returned as `Viewtype::Unknown`
/// and not as errors bubbled up, easily resulting in infinite loop or leaving messages undeleted.
/// (Happens when viewtypes are removed or added on another device which was backup/add-second-device source)
async fn select_expired_messages(
context: &Context,
now: i64,
) -> Result<Vec<(MsgId, ChatId, Viewtype, u32)>> {
let mut rows = context
.sql
.query_map(
.query_map_vec(
r#"
SELECT id, chat_id, type, location_id
FROM msgs
@@ -395,11 +399,14 @@ WHERE
|row| {
let id: MsgId = row.get("id")?;
let chat_id: ChatId = row.get("chat_id")?;
let viewtype: Viewtype = row.get("type")?;
let viewtype: Viewtype = row
.get("type")
.context("Using default viewtype for ephemeral handling.")
.log_err(context)
.unwrap_or_default();
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
@@ -417,7 +424,7 @@ WHERE
let rows_expired = context
.sql
.query_map(
.query_map_vec(
r#"
SELECT id, chat_id, type, location_id
FROM msgs
@@ -437,11 +444,14 @@ WHERE
|row| {
let id: MsgId = row.get("id")?;
let chat_id: ChatId = row.get("chat_id")?;
let viewtype: Viewtype = row.get("type")?;
let viewtype: Viewtype = row
.get("type")
.context("Using default viewtype for delete-old handling.")
.log_err(context)
.unwrap_or_default();
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
@@ -598,7 +608,7 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
+ Duration::from_secs(1)
} else {
// no messages to be deleted for now, wait long for one to occur
now + Duration::from_secs(86400)
now + Duration::from_secs(86400) // 1 day
};
if let Ok(duration) = until.duration_since(now) {
@@ -625,6 +635,12 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
}
}
// Make sure that the statistics stay correct by updating them _before_ deleting messages:
stats::maybe_update_message_stats(context)
.await
.log_err(context)
.ok();
delete_expired_messages(context, time())
.await
.log_err(context)

View File

@@ -12,7 +12,7 @@ use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE;
use crate::{
chat::{self, Chat, ChatItem, ProtectionStatus, create_group_chat, send_text_msg},
chat::{self, Chat, ChatItem, create_group, send_text_msg},
tools::IsNoneOrEmpty,
};
@@ -164,7 +164,7 @@ async fn test_ephemeral_enable_disable() -> Result<()> {
async fn test_ephemeral_unpromoted() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group name").await?;
let chat_id = create_group(&alice, "Group name").await?;
// Group is unpromoted, the timer can be changed without sending a message.
assert!(chat_id.is_unpromoted(&alice).await?);
@@ -799,8 +799,7 @@ async fn test_ephemeral_timer_non_member() -> Result<()> {
let bob = &tcm.bob().await;
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
let alice_chat_id =
create_group_chat(alice, ProtectionStatus::Unprotected, "Group name").await?;
let alice_chat_id = create_group(alice, "Group name").await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
send_text_msg(alice, alice_chat_id, "Hi!".to_string()).await?;
@@ -826,3 +825,68 @@ async fn test_ephemeral_timer_non_member() -> Result<()> {
Ok(())
}
/// Tests that expiration of a disappearing message
/// with unknown viewtype does not make `delete_expired_messages` fail.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_disappearing_unknown_viewtype() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.await?;
let mut msg = Message::new_text("Expiring message".to_string());
let _alice_sent_message = alice.send_msg(chat.id, &mut msg).await;
// Set message viewtype to unassigned
// type 70 that was previously used for videochat invitations.
alice
.sql
.execute("UPDATE msgs SET type=70 WHERE id=?", (msg.id,))
.await?;
SystemTime::shift(Duration::from_secs(100));
// This should not fail.
delete_expired_messages(alice, time()).await?;
Ok(())
}
/// Tests that deletion of a message with unknown viewtype
/// triggered by `delete_device_after`
/// does not make `delete_expired_messages` fail.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_device_after_unknown_viewtype() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
alice
.set_config(Config::DeleteDeviceAfter, Some("600"))
.await?;
let mut msg = Message::new_text("Some message".to_string());
let _alice_sent_message = alice.send_msg(chat.id, &mut msg).await;
// Set message viewtype to unassigned
// type 70 that was previously used for videochat invitations.
alice
.sql
.execute("UPDATE msgs SET type=70 WHERE id=?", (msg.id,))
.await?;
SystemTime::shift(Duration::from_secs(1000));
// This should not fail.
delete_expired_messages(alice, time()).await?;
Ok(())
}

View File

@@ -66,8 +66,7 @@ mod test_chatlist_events {
use crate::{
EventType,
chat::{
self, ChatId, ChatVisibility, MuteDuration, ProtectionStatus, create_broadcast,
create_group_chat, set_muted,
self, ChatId, ChatVisibility, MuteDuration, create_broadcast, create_group, set_muted,
},
config::Config,
constants::*,
@@ -138,12 +137,7 @@ mod test_chatlist_events {
async fn test_change_chat_visibility() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat_id = create_group_chat(
&alice,
crate::chat::ProtectionStatus::Unprotected,
"my_group",
)
.await?;
let chat_id = create_group(&alice, "my_group").await?;
chat_id
.set_visibility(&alice, ChatVisibility::Pinned)
@@ -289,7 +283,7 @@ mod test_chatlist_events {
async fn test_delete_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
alice.evtracker.clear_events();
chat.delete(&alice).await?;
@@ -299,11 +293,11 @@ mod test_chatlist_events {
/// Create group chat
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_group_chat() -> Result<()> {
async fn test_create_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice.evtracker.clear_events();
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
wait_for_chatlist_and_specific_item(&alice, chat).await;
Ok(())
}
@@ -324,7 +318,7 @@ mod test_chatlist_events {
async fn test_mute_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
alice.evtracker.clear_events();
chat::set_muted(&alice, chat, MuteDuration::Forever).await?;
@@ -343,7 +337,7 @@ mod test_chatlist_events {
async fn test_mute_chat_expired() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
let mute_duration = MuteDuration::Until(
std::time::SystemTime::now()
@@ -363,7 +357,7 @@ mod test_chatlist_events {
async fn test_change_chat_name() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
alice.evtracker.clear_events();
chat::set_chat_name(&alice, chat, "New Name").await?;
@@ -377,7 +371,7 @@ mod test_chatlist_events {
async fn test_change_chat_profile_image() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
alice.evtracker.clear_events();
let file = alice.dir.path().join("avatar.png");
@@ -395,9 +389,7 @@ mod test_chatlist_events {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let chat = alice.create_group_with_members("My Group", &[&bob]).await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
@@ -419,9 +411,7 @@ mod test_chatlist_events {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let chat = alice.create_group_with_members("My Group", &[&bob]).await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
@@ -438,9 +428,7 @@ mod test_chatlist_events {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let chat = alice.create_group_with_members("My Group", &[&bob]).await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
@@ -456,7 +444,7 @@ mod test_chatlist_events {
async fn test_delete_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
let message = chat::send_text_msg(&alice, chat, "Hello World".to_owned()).await?;
alice.evtracker.clear_events();
@@ -473,9 +461,7 @@ mod test_chatlist_events {
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let chat = alice.create_group_with_members("My Group", &[&bob]).await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
chat_id_for_bob.accept(&bob).await?;
@@ -516,7 +502,7 @@ mod test_chatlist_events {
async fn test_update_after_ephemeral_messages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
chat.set_ephemeral_timer(&alice, crate::ephemeral::Timer::Enabled { duration: 60 })
.await?;
alice
@@ -560,8 +546,7 @@ First thread."#;
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice_chatid =
chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?;
let alice_chatid = chat::create_group(&alice.ctx, "the chat").await?;
// Step 1: Generate QR-code, secure-join implied by chatid
let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid)).await?;
@@ -608,7 +593,7 @@ First thread."#;
async fn test_resend_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?;
let _ = alice.pop_sent_msg().await;
@@ -628,7 +613,7 @@ First thread."#;
async fn test_reaction() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let chat = create_group(&alice, "My Group").await?;
let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?;
let _ = alice.pop_sent_msg().await;

View File

@@ -60,7 +60,12 @@ pub enum HeaderDef {
ChatGroupNameTimestamp,
ChatVerified,
ChatGroupAvatar,
/// If present, contact's avatar and status should be applied from the message.
/// "Chat-User-Avatar: 0" means that the contact has no avatar. Contact's status is transferred
/// in the message footer.
ChatUserAvatar,
ChatVoiceMessage,
ChatGroupMemberRemoved,
ChatGroupMemberAdded,

View File

@@ -620,71 +620,38 @@ impl Imap {
// Determine the target folder where the message should be moved to.
//
// If we have seen the message on the IMAP server before, do not move it.
// We only move the messages from the INBOX and Spam folders.
// This is required to avoid infinite MOVE loop on IMAP servers
// that alias `DeltaChat` folder to other names.
// For example, some Dovecot servers alias `DeltaChat` folder to `INBOX.DeltaChat`.
// In this case Delta Chat configured with `DeltaChat` as the destination folder
// would detect messages in the `INBOX.DeltaChat` folder
// and try to move them to the `DeltaChat` folder.
// Such move to the same folder results in the messages
// getting a new UID, so the messages will be detected as new
// In this case moving from `INBOX.DeltaChat` to `DeltaChat`
// results in the messages getting a new UID,
// so the messages will be detected as new
// in the `INBOX.DeltaChat` folder again.
let _target;
let target = if let Some(message_id) = &message_id {
let msg_info =
message::rfc724_mid_exists_ex(context, message_id, "deleted=1").await?;
let delete = if let Some((_, _, true)) = msg_info {
info!(context, "Deleting locally deleted message {message_id}.");
true
} else if let Some((_, ts_sent_old, _)) = msg_info {
let is_chat_msg = headers.get_header_value(HeaderDef::ChatVersion).is_some();
let ts_sent = headers
.get_header_value(HeaderDef::Date)
.and_then(|v| mailparse::dateparse(&v).ok())
.unwrap_or_default();
let is_dup = is_dup_msg(is_chat_msg, ts_sent, ts_sent_old);
if is_dup {
info!(context, "Deleting duplicate message {message_id}.");
}
is_dup
} else {
false
};
if delete {
&delete_target
} else if context
.sql
.exists(
"SELECT COUNT (*) FROM imap WHERE rfc724_mid=?",
(message_id,),
)
let delete = if let Some(message_id) = &message_id {
message::rfc724_mid_exists_ex(context, message_id, "deleted=1")
.await?
{
info!(
context,
"Not moving the message {} that we have seen before.", &message_id
);
folder
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
}
.is_some_and(|(_msg_id, deleted)| deleted)
} else {
// Do not move the messages without Message-ID.
// We cannot reliably determine if we have seen them before,
// so it is safer not to move them.
warn!(
context,
"Not moving the message that does not have a Message-ID."
);
folder
false
};
// Generate a fake Message-ID to identify the message in the database
// if the message has no real Message-ID.
let message_id = message_id.unwrap_or_else(create_message_id);
if delete {
info!(context, "Deleting locally deleted message {message_id}.");
}
let _target;
let target = if delete {
&delete_target
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
};
context
.sql
.execute(
@@ -768,7 +735,6 @@ impl Imap {
.fetch_many_msgs(
context,
folder,
uid_validity,
uids_fetch_in_batch.split_off(0),
&uid_message_ids,
fetch_partially,
@@ -1070,7 +1036,7 @@ impl Session {
async fn move_delete_messages(&mut self, context: &Context, folder: &str) -> Result<()> {
let rows = context
.sql
.query_map(
.query_map_vec(
"SELECT id, uid, target FROM imap
WHERE folder = ?
AND target != folder
@@ -1082,7 +1048,6 @@ impl Session {
let target: String = row.get(2)?;
Ok((rowid, uid, target))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
@@ -1173,7 +1138,7 @@ impl Session {
pub(crate) async fn store_seen_flags_on_imap(&mut self, context: &Context) -> Result<()> {
let rows = context
.sql
.query_map(
.query_map_vec(
"SELECT imap.id, uid, folder FROM imap, imap_markseen
WHERE imap.id = imap_markseen.id AND target = folder
ORDER BY folder, uid",
@@ -1184,7 +1149,6 @@ impl Session {
let folder: String = row.get(2)?;
Ok((rowid, uid, folder))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
@@ -1383,12 +1347,10 @@ impl Session {
///
/// If the message is incorrect or there is a failure to write a message to the database,
/// it is skipped and the error is logged.
#[expect(clippy::too_many_arguments)]
pub(crate) async fn fetch_many_msgs(
&mut self,
context: &Context,
folder: &str,
uidvalidity: u32,
request_uids: Vec<u32>,
uid_message_ids: &BTreeMap<u32, String>,
fetch_partially: bool,
@@ -1514,9 +1476,6 @@ impl Session {
);
let res = receive_imf_inner(
context,
folder,
uidvalidity,
request_uid,
rfc724_mid,
body,
is_seen,
@@ -1530,9 +1489,6 @@ impl Session {
}
receive_imf_inner(
context,
folder,
uidvalidity,
request_uid,
rfc724_mid,
body,
is_seen,
@@ -2112,7 +2068,9 @@ pub async fn target_folder_cfg(
if folder_meaning == FolderMeaning::Spam {
spam_target_folder_cfg(context, headers).await
} else if needs_move_to_mvbox(context, headers).await? {
} else if folder_meaning == FolderMeaning::Inbox
&& needs_move_to_mvbox(context, headers).await?
{
Ok(Some(Config::ConfiguredMvboxFolder))
} else {
Ok(None)
@@ -2260,7 +2218,9 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
];
let lower = folder_name.to_lowercase();
if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
if lower == "inbox" {
FolderMeaning::Inbox
} else if SENT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
FolderMeaning::Sent
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
FolderMeaning::Spam
@@ -2416,15 +2376,6 @@ pub(crate) async fn prefetch_should_download(
Ok(should_download)
}
/// Returns whether a message is a duplicate (resent message).
pub(crate) fn is_dup_msg(is_chat_msg: bool, ts_sent: i64, ts_sent_old: i64) -> bool {
// If the existing message has timestamp_sent == 0, that means we don't know its actual sent
// timestamp, so don't delete the new message. E.g. outgoing messages have zero timestamp_sent
// because they are stored to the db before sending. Also consider as duplicates only messages
// with greater timestamp to avoid deleting both messages in a multi-device setting.
is_chat_msg && ts_sent_old != 0 && ts_sent > ts_sent_old
}
/// Marks messages in `msgs` table as seen, searching for them by UID.
///
/// Returns updated chat ID if any message was marked as seen.

View File

@@ -37,12 +37,12 @@ impl DerefMut for Client {
}
/// Converts port number to ALPN list.
fn alpn(port: u16) -> &'static [&'static str] {
fn alpn(port: u16) -> &'static str {
if port == 993 {
// Do not request ALPN on standard port.
&[]
""
} else {
&["imap"]
"imap"
}
}
@@ -210,7 +210,15 @@ impl Client {
let account_id = context.get_id();
let events = context.events.clone();
let logging_stream = LoggingStream::new(tcp_stream, account_id, events)?;
let tls_stream = wrap_tls(strict_tls, hostname, alpn(addr.port()), logging_stream).await?;
let tls_stream = wrap_tls(
strict_tls,
hostname,
addr.port(),
alpn(addr.port()),
logging_stream,
&context.tls_session_store,
)
.await?;
let buffered_stream = BufWriter::new(tls_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let mut client = Client::new(session_stream);
@@ -262,9 +270,16 @@ impl Client {
let buffered_tcp_stream = client.into_inner();
let tcp_stream = buffered_tcp_stream.into_inner();
let tls_stream = wrap_tls(strict_tls, host, &[], tcp_stream)
.await
.context("STARTTLS upgrade failed")?;
let tls_stream = wrap_tls(
strict_tls,
host,
addr.port(),
"",
tcp_stream,
&context.tls_session_store,
)
.await
.context("STARTTLS upgrade failed")?;
let buffered_stream = BufWriter::new(tls_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let client = Client::new(session_stream);
@@ -281,7 +296,15 @@ impl Client {
let proxy_stream = proxy_config
.connect(context, domain, port, strict_tls)
.await?;
let tls_stream = wrap_tls(strict_tls, domain, alpn(port), proxy_stream).await?;
let tls_stream = wrap_tls(
strict_tls,
domain,
port,
alpn(port),
proxy_stream,
&context.tls_session_store,
)
.await?;
let buffered_stream = BufWriter::new(tls_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let mut client = Client::new(session_stream);
@@ -334,9 +357,16 @@ impl Client {
let buffered_proxy_stream = client.into_inner();
let proxy_stream = buffered_proxy_stream.into_inner();
let tls_stream = wrap_tls(strict_tls, hostname, &[], proxy_stream)
.await
.context("STARTTLS upgrade failed")?;
let tls_stream = wrap_tls(
strict_tls,
hostname,
port,
"",
proxy_stream,
&context.tls_session_store,
)
.await
.context("STARTTLS upgrade failed")?;
let buffered_stream = BufWriter::new(tls_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let client = Client::new(session_stream);

View File

@@ -186,7 +186,7 @@ const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
("Sent", false, false, "Sent"),
("Sent", false, true, "Sent"),
("Sent", true, false, "Sent"),
("Sent", true, true, "DeltaChat"),
("Sent", true, true, "Sent"),
("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"),
("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
@@ -202,7 +202,7 @@ const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
("Sent", false, false, "Sent"),
("Sent", false, true, "Sent"),
("Sent", true, false, "Sent"),
("Sent", true, true, "DeltaChat"),
("Sent", true, true, "Sent"),
("Spam", false, false, "Spam"),
("Spam", false, true, "INBOX"),
("Spam", true, false, "Spam"),

View File

@@ -18,7 +18,7 @@ impl Imap {
) -> Result<bool> {
// First of all, debounce to once per minute:
{
let mut last_scan = context.last_full_folder_scan.lock().await;
let mut last_scan = session.last_full_folder_scan.lock().await;
if let Some(last_scan) = *last_scan {
let elapsed_secs = time_elapsed(&last_scan).as_secs();
let debounce_secs = context

View File

@@ -5,9 +5,11 @@ use anyhow::{Context as _, Result};
use async_imap::Session as ImapSession;
use async_imap::types::Mailbox;
use futures::TryStreamExt;
use tokio::sync::Mutex;
use crate::imap::capabilities::Capabilities;
use crate::net::session::SessionStream;
use crate::tools;
/// Prefetch:
/// - Message-ID to check if we already have the message.
@@ -40,6 +42,8 @@ pub(crate) struct Session {
pub selected_folder_needs_expunge: bool,
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
/// True if currently selected folder has new messages.
///
/// Should be false if no folder is currently selected.
@@ -71,6 +75,7 @@ impl Session {
selected_folder: None,
selected_mailbox: None,
selected_folder_needs_expunge: false,
last_full_folder_scan: Mutex::new(None),
new_mail: false,
}
}

View File

@@ -648,7 +648,7 @@ async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
let keys = context
.sql
.query_map(
.query_map_vec(
"SELECT id, public_key, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
(),
|row| {
@@ -661,10 +661,6 @@ async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
Ok((id, public_key, private_key, is_default))
},
|keys| {
keys.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await?;
let self_addr = context.get_primary_self_addr().await?;

View File

@@ -15,6 +15,7 @@ use rand::thread_rng;
use tokio::runtime::Handle;
use crate::context::Context;
use crate::events::EventType;
use crate::log::{LogExt, info};
use crate::pgp::KeyPair;
use crate::tools::{self, time_elapsed};
@@ -24,7 +25,7 @@ use crate::tools::{self, time_elapsed};
/// This trait is implemented for rPGP's [SignedPublicKey] and
/// [SignedSecretKey] types and makes working with them a little
/// easier in the deltachat world.
pub(crate) trait DcKey: Serialize + Deserializable + Clone {
pub trait DcKey: Serialize + Deserializable + Clone {
/// Create a key from some bytes.
fn from_slice(bytes: &[u8]) -> Result<Self> {
let res = <Self as Deserializable>::from_bytes(Cursor::new(bytes));
@@ -111,7 +112,10 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone {
/// The fingerprint for the key.
fn dc_fingerprint(&self) -> Fingerprint;
/// Whether the key is private (or public).
fn is_private() -> bool;
/// Returns the OpenPGP Key ID.
fn key_id(&self) -> KeyId;
}
@@ -414,15 +418,11 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
"INSERT INTO config (keyname, value) VALUES ('key_id', ?)",
(new_key_id,),
)?;
Ok(Some(new_key_id))
Ok(new_key_id)
})
.await?;
if let Some(new_key_id) = new_key_id {
// Update config cache if transaction succeeded and changed current default key.
config_cache_lock.insert("key_id".to_string(), Some(new_key_id.to_string()));
}
context.emit_event(EventType::AccountsItemChanged);
config_cache_lock.insert("key_id".to_string(), Some(new_key_id.to_string()));
Ok(())
}
@@ -500,7 +500,7 @@ impl std::str::FromStr for Fingerprint {
.filter(|&c| c.is_ascii_hexdigit())
.collect();
let v: Vec<u8> = hex::decode(&hex_repr)?;
ensure!(v.len() == 20, "wrong fingerprint length: {}", hex_repr);
ensure!(v.len() == 20, "wrong fingerprint length: {hex_repr}");
let fp = Fingerprint::new(v);
Ok(fp)
}

View File

@@ -99,6 +99,9 @@ pub mod html;
pub mod net;
pub mod plaintext;
mod push;
mod stats;
pub use stats::SecurejoinSource;
pub use stats::SecurejoinUiPath;
pub mod summary;
mod debug_logging;

View File

@@ -140,7 +140,7 @@ impl Kml {
if self.tag == KmlTag::PlacemarkTimestampWhen
|| self.tag == KmlTag::PlacemarkPointCoordinates
{
let val = event.unescape().unwrap_or_default();
let val = event.xml_content().unwrap_or_default();
let val = val.replace(['\n', '\r', '\t', ' '], "");
@@ -345,15 +345,10 @@ pub async fn set(context: &Context, latitude: f64, longitude: f64, accuracy: f64
let chats = context
.sql
.query_map(
.query_map_vec(
"SELECT id FROM chats WHERE locations_send_until>?;",
(now,),
|row| row.get::<_, i32>(0),
|chats| {
chats
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await?;
@@ -408,7 +403,7 @@ pub async fn get_range(
};
let list = context
.sql
.query_map(
.query_map_vec(
"SELECT l.id, l.latitude, l.longitude, l.accuracy, l.timestamp, l.independent, \
COALESCE(m.id, 0) AS msg_id, l.from_id, l.chat_id, COALESCE(m.txt, '') AS txt \
FROM locations l LEFT JOIN msgs m ON l.id=m.location_id WHERE (? OR l.chat_id=?) \
@@ -445,14 +440,6 @@ pub async fn get_range(
};
Ok(loc)
},
|locations| {
let mut ret = Vec::new();
for location in locations {
ret.push(location?);
}
Ok(ret)
},
)
.await?;
Ok(list)
@@ -768,7 +755,7 @@ async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
let now = time();
let rows = context
.sql
.query_map(
.query_map_vec(
"SELECT id, locations_send_begin, locations_send_until, locations_last_sent
FROM chats
WHERE locations_send_until>0",
@@ -785,10 +772,6 @@ async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
locations_last_sent,
))
},
|rows| {
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await
.context("failed to query location streaming chats")?;

View File

@@ -170,7 +170,7 @@ impl MsgId {
) -> Result<Vec<String>> {
context
.sql
.query_map(
.query_map_vec(
"SELECT folder, uid FROM imap WHERE rfc724_mid=?",
(rfc724_mid,),
|row| {
@@ -178,10 +178,6 @@ impl MsgId {
let uid: u32 = row.get("uid")?;
Ok(format!("</{folder}/;UID={uid}>"))
},
|rows| {
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await
}
@@ -240,7 +236,7 @@ impl MsgId {
if let Ok(rows) = context
.sql
.query_map(
.query_map_vec(
"SELECT contact_id, timestamp_sent FROM msgs_mdns WHERE msg_id=?",
(self,),
|row| {
@@ -248,7 +244,6 @@ impl MsgId {
let ts: i64 = row.get(1)?;
Ok((contact_id, ts))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await
{
@@ -490,8 +485,7 @@ impl Message {
pub async fn load_from_db_optional(context: &Context, id: MsgId) -> Result<Option<Message>> {
ensure!(
!id.is_special(),
"Can not load special message ID {} from DB",
id
"Can not load special message ID {id} from DB"
);
let msg = context
.sql
@@ -1235,7 +1229,7 @@ impl Message {
/// `References` header is not taken into account.
pub async fn parent(&self, context: &Context) -> Result<Option<Message>> {
if let Some(in_reply_to) = &self.in_reply_to {
if let Some((msg_id, _ts_sent)) = rfc724_mid_exists(context, in_reply_to).await? {
if let Some(msg_id) = rfc724_mid_exists(context, in_reply_to).await? {
let msg = Message::load_from_db_optional(context, msg_id).await?;
return Ok(msg);
}
@@ -1427,7 +1421,7 @@ pub async fn get_msg_read_receipts(
) -> Result<Vec<(ContactId, i64)>> {
context
.sql
.query_map(
.query_map_vec(
"SELECT contact_id, timestamp_sent FROM msgs_mdns WHERE msg_id=?",
(msg_id,),
|row| {
@@ -1435,7 +1429,6 @@ pub async fn get_msg_read_receipts(
let ts: i64 = row.get(1)?;
Ok((contact_id, ts))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await
}
@@ -2044,13 +2037,13 @@ pub async fn estimate_deletion_cnt(
pub(crate) async fn rfc724_mid_exists(
context: &Context,
rfc724_mid: &str,
) -> Result<Option<(MsgId, i64)>> {
) -> Result<Option<MsgId>> {
Ok(rfc724_mid_exists_ex(context, rfc724_mid, "1")
.await?
.map(|(id, ts_sent, _)| (id, ts_sent)))
.map(|(id, _)| id))
}
/// Returns [MsgId] and "sent" timestamp of the most recent message with given `rfc724_mid`
/// Returns [MsgId] of the most recent message with given `rfc724_mid`
/// (Message-ID header) and bool `expr` result if such messages exists in the db.
///
/// * `expr`: SQL expression additionally passed into `SELECT`. Evaluated to `true` iff it is true
@@ -2059,7 +2052,7 @@ pub(crate) async fn rfc724_mid_exists_ex(
context: &Context,
rfc724_mid: &str,
expr: &str,
) -> Result<Option<(MsgId, i64, bool)>> {
) -> Result<Option<(MsgId, bool)>> {
let rfc724_mid = rfc724_mid.trim_start_matches('<').trim_end_matches('>');
if rfc724_mid.is_empty() {
warn!(context, "Empty rfc724_mid passed to rfc724_mid_exists");
@@ -2077,9 +2070,8 @@ pub(crate) async fn rfc724_mid_exists_ex(
(rfc724_mid,),
|row| {
let msg_id: MsgId = row.get(0)?;
let timestamp_sent: i64 = row.get(1)?;
let expr_res: bool = row.get(2)?;
Ok((msg_id, timestamp_sent, expr_res))
Ok((msg_id, expr_res))
},
)
.await?;
@@ -2099,7 +2091,7 @@ pub(crate) async fn get_by_rfc724_mids(
) -> Result<Option<Message>> {
let mut latest = None;
for id in mids.iter().rev() {
let Some((msg_id, _)) = rfc724_mid_exists(context, id).await? else {
let Some(msg_id) = rfc724_mid_exists(context, id).await? else {
continue;
};
let Some(msg) = Message::load_from_db_optional(context, msg_id).await? else {

View File

@@ -11,6 +11,7 @@ use iroh_gossip::proto::TopicId;
use mail_builder::headers::HeaderType;
use mail_builder::headers::address::{Address, EmailAddress};
use mail_builder::mime::MimePart;
use rand::Rng as _;
use tokio::fs;
use crate::aheader::{Aheader, EncryptPreference};
@@ -181,7 +182,7 @@ impl MimeFactory {
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let now = time();
let chat = Chat::load_from_db(context, msg.chat_id).await?;
let attach_profile_data = Self::should_attach_profile_data(&msg);
let can_transfer_profile = Self::can_transfer_profile(&msg);
let undisclosed_recipients = chat.typ == Chattype::OutBroadcast;
let from_addr = context.get_primary_self_addr().await?;
@@ -193,7 +194,7 @@ impl MimeFactory {
if let Some(override_name) = msg.param.get(Param::OverrideSenderDisplayname) {
(override_name.to_string(), Some(config_displayname))
} else {
let name = match attach_profile_data {
let name = match can_transfer_profile {
true => config_displayname,
false => "".to_string(),
};
@@ -301,7 +302,7 @@ impl MimeFactory {
} else {
addr
};
let name = match attach_profile_data {
let name = match can_transfer_profile {
true => authname,
false => "".to_string(),
};
@@ -419,10 +420,7 @@ impl MimeFactory {
None
} else {
if keys.is_empty() && !recipients.is_empty() {
bail!(
"No recipient keys are available, cannot encrypt to {:?}.",
recipients
);
bail!("No recipient keys are available, cannot encrypt to {recipients:?}.");
}
// Remove recipients for which the key is missing.
@@ -453,14 +451,18 @@ impl MimeFactory {
.split_ascii_whitespace()
.map(|s| s.trim_start_matches('<').trim_end_matches('>').to_string())
.collect();
let selfstatus = match attach_profile_data {
let should_attach_profile = Self::should_attach_profile(context, &msg).await;
// TODO: (2025-08) Attach self-status in every message for compatibility with older
// versions. Should be replaced with
// `should_attach_profile || !is_encrypted && can_transfer_profile`.
let selfstatus = match can_transfer_profile {
true => context
.get_config(Config::Selfstatus)
.await?
.unwrap_or_default(),
false => "".to_string(),
};
let attach_selfavatar = Self::should_attach_selfavatar(context, &msg).await;
let attach_selfavatar = should_attach_profile;
ensure_and_debug_assert!(
member_timestamps.is_empty()
@@ -549,11 +551,11 @@ impl MimeFactory {
Loaded::Message { msg, .. } => {
msg.param.get_bool(Param::SkipAutocrypt).unwrap_or_default()
}
Loaded::Mdn { .. } => false,
Loaded::Mdn { .. } => true,
}
}
fn should_attach_profile_data(msg: &Message) -> bool {
fn can_transfer_profile(msg: &Message) -> bool {
msg.param.get_cmd() != SystemMessage::SecurejoinMessage || {
let step = msg.param.get(Param::Arg).unwrap_or_default();
// Don't attach profile data at the earlier SecureJoin steps:
@@ -568,14 +570,14 @@ impl MimeFactory {
}
}
async fn should_attach_selfavatar(context: &Context, msg: &Message) -> bool {
Self::should_attach_profile_data(msg)
&& match chat::shall_attach_selfavatar(context, msg.chat_id).await {
async fn should_attach_profile(context: &Context, msg: &Message) -> bool {
Self::can_transfer_profile(msg)
&& match chat::should_attach_profile(context, msg.chat_id).await {
Ok(should) => should,
Err(err) => {
warn!(
context,
"should_attach_selfavatar: cannot get selfavatar state: {err:#}."
"should_attach_profile: chat::should_attach_profile: {err:#}."
);
false
}
@@ -640,7 +642,7 @@ impl MimeFactory {
return Ok(format!("Re: {}", remove_subject_prefix(last_subject)));
}
let self_name = match Self::should_attach_profile_data(msg) {
let self_name = match Self::can_transfer_profile(msg) {
true => context.get_config(Config::Displayname).await?,
false => None,
};
@@ -967,10 +969,6 @@ impl MimeFactory {
hidden_headers.push(header.clone());
} else if is_hidden(&header_name) {
hidden_headers.push(header.clone());
} else if header_name == "autocrypt"
&& !context.get_config_bool(Config::ProtectAutocrypt).await?
{
unprotected_headers.push(header.clone());
} else if header_name == "from" {
// Unencrypted securejoin messages should _not_ include the display name:
if is_encrypted || !is_securejoin_message {
@@ -1005,6 +1003,32 @@ impl MimeFactory {
} else {
unprotected_headers.push(header.clone());
}
} else if is_encrypted && header_name == "date" {
protected_headers.push(header.clone());
// Randomized date goes to unprotected header.
//
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
// or omit the header because GMX then fails with
//
// host mx00.emig.gmx.net[212.227.15.9] said:
// 554-Transaction failed
// 554-Reject due to policy restrictions.
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
// (in reply to end of DATA command)
//
// and the explanation page says
// "The time information deviates too much from the actual time".
let timestamp_offset = rand::thread_rng().gen_range(0..1000000);
let protected_timestamp = self.timestamp.saturating_sub(timestamp_offset);
let unprotected_date =
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
.unwrap()
.to_rfc2822();
unprotected_headers.push((
"Date",
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
));
} else if is_encrypted {
protected_headers.push(header.clone());
@@ -1015,8 +1039,7 @@ impl MimeFactory {
mail_builder::headers::raw::Raw::new("[...]").into(),
));
}
"date"
| "in-reply-to"
"in-reply-to"
| "references"
| "auto-submitted"
| "chat-version"
@@ -1088,6 +1111,17 @@ impl MimeFactory {
.is_none_or(|ts| now >= ts + gossip_period || now < ts)
};
let verifier_id: Option<u32> = context
.sql
.query_get_value(
"SELECT verifier FROM contacts WHERE fingerprint=?",
(&fingerprint,),
)
.await?;
let is_verified =
verifier_id.is_some_and(|verifier_id| verifier_id != 0);
if !should_do_gossip {
continue;
}
@@ -1098,7 +1132,7 @@ impl MimeFactory {
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
verified: false,
verified: is_verified,
}
.to_string();
@@ -1323,20 +1357,6 @@ impl MimeFactory {
let command = msg.param.get_cmd();
let mut placeholdertext = None;
let send_verified_headers = match chat.typ {
Chattype::Single => true,
Chattype::Group => true,
// Mailinglists and broadcast channels can actually never be verified:
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => false,
};
if chat.is_protected() && send_verified_headers {
headers.push((
"Chat-Verified",
mail_builder::headers::raw::Raw::new("1").into(),
));
}
if chat.typ == Chattype::Group {
// Send group ID unless it is an ad hoc group that has no ID.
if !chat.grpid.is_empty() {

View File

@@ -6,8 +6,7 @@ use std::time::Duration;
use super::*;
use crate::chat::{
self, ChatId, ProtectionStatus, add_contact_to_chat, create_group_chat,
remove_contact_from_chat, send_text_msg,
self, ChatId, add_contact_to_chat, create_group, remove_contact_from_chat, send_text_msg,
};
use crate::chatlist::Chatlist;
use crate::constants;
@@ -352,9 +351,7 @@ async fn test_subject_in_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = tcm.alice().await;
let bob = tcm.bob().await;
let group_id = chat::create_group_chat(&t, chat::ProtectionStatus::Unprotected, "groupname")
.await
.unwrap();
let group_id = chat::create_group(&t, "groupname").await.unwrap();
let bob_contact_id = t.add_or_lookup_contact_id(&bob).await;
chat::add_contact_to_chat(&t, group_id, bob_contact_id).await?;
@@ -666,7 +663,7 @@ async fn test_selfavatar_unencrypted_signed() {
assert_eq!(part.match_indices("From:").count(), 1);
assert_eq!(part.match_indices("Message-ID:").count(), 0);
assert_eq!(part.match_indices("Subject:").count(), 1);
assert_eq!(part.match_indices("Autocrypt:").count(), 0);
assert_eq!(part.match_indices("Autocrypt:").count(), 1);
assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0);
let part = payload.next().unwrap();
@@ -717,7 +714,7 @@ async fn test_selfavatar_unencrypted_signed() {
assert_eq!(part.match_indices("From:").count(), 1);
assert_eq!(part.match_indices("Message-ID:").count(), 0);
assert_eq!(part.match_indices("Subject:").count(), 1);
assert_eq!(part.match_indices("Autocrypt:").count(), 0);
assert_eq!(part.match_indices("Autocrypt:").count(), 1);
assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0);
let part = payload.next().unwrap();
@@ -756,7 +753,7 @@ async fn test_remove_member_bcc() -> Result<()> {
let charlie_contact = Contact::get_by_id(alice, charlie_id).await?;
let charlie_addr = charlie_contact.get_addr();
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo").await?;
let alice_chat_id = create_group(alice, "foo").await?;
add_contact_to_chat(alice, alice_chat_id, bob_id).await?;
add_contact_to_chat(alice, alice_chat_id, charlie_id).await?;
send_text_msg(alice, alice_chat_id, "Creating a group".to_string()).await?;
@@ -846,16 +843,12 @@ async fn test_dont_remove_self() -> Result<()> {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let first_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob])
.await;
let first_group = alice.create_group_with_members("First group", &[bob]).await;
alice.send_text(first_group, "Hi! I created a group.").await;
remove_contact_from_chat(alice, first_group, ContactId::SELF).await?;
alice.pop_sent_msg().await;
let second_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob])
.await;
let second_group = alice.create_group_with_members("First group", &[bob]).await;
let sent = alice
.send_text(second_group, "Hi! I created another group.")
.await;
@@ -883,9 +876,7 @@ async fn test_new_member_is_first_recipient() -> Result<()> {
let bob_id = alice.add_or_lookup_contact_id(bob).await;
let charlie_id = alice.add_or_lookup_contact_id(charlie).await;
let group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob])
.await;
let group = alice.create_group_with_members("Group", &[bob]).await;
alice.send_text(group, "Hi! I created a group.").await;
SystemTime::shift(Duration::from_secs(60));

View File

@@ -34,7 +34,7 @@ use crate::sync::SyncItems;
use crate::tools::{
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
};
use crate::{chatlist_events, location, stock_str, tools};
use crate::{chatlist_events, location, tools};
/// Public key extracted from `Autocrypt-Gossip`
/// header with associated information.
@@ -87,12 +87,12 @@ pub(crate) struct MimeMessage {
pub chat_disposition_notification_to: Option<SingleInfo>,
pub decrypting_failed: bool,
/// Set of valid signature fingerprints if a message is an
/// Valid signature fingerprint if a message is an
/// Autocrypt encrypted and signed message.
///
/// If a message is not encrypted or the signature is not valid,
/// this set is empty.
pub signatures: HashSet<Fingerprint>,
/// this is `None`.
pub signature: Option<Fingerprint>,
/// The addresses for which there was a gossip header
/// and their respective gossiped keys.
@@ -589,7 +589,7 @@ impl MimeMessage {
decrypting_failed: mail.is_err(),
// only non-empty if it was a valid autocrypt message
signatures,
signature: signatures.into_iter().last(),
autocrypt_fingerprint,
gossiped_keys,
is_forwarded: false,
@@ -622,13 +622,12 @@ impl MimeMessage {
parser.parse_mime_recursive(context, mail, false).await?;
}
Err(err) => {
let msg_body = stock_str::cant_decrypt_msg_body(context).await;
let txt = format!("[{msg_body}]");
let txt = "[This message cannot be decrypted.\n\n• It might already help to simply reply to this message and ask the sender to send the message again.\n\n• If you just re-installed Delta Chat then it is best if you re-setup Delta Chat now and choose \"Add as second device\" or import a backup.]";
let part = Part {
typ: Viewtype::Text,
msg_raw: Some(txt.clone()),
msg: txt,
msg_raw: Some(txt.to_string()),
msg: txt.to_string(),
// Don't change the error prefix for now,
// receive_imf.rs:lookup_chat_by_reply() checks it.
error: Some(format!("Decrypting failed: {err:#}")),
@@ -966,7 +965,7 @@ impl MimeMessage {
/// This means the message was both encrypted and signed with a
/// valid signature.
pub fn was_encrypted(&self) -> bool {
!self.signatures.is_empty()
self.signature.is_some()
}
/// Returns whether the email contains a `chat-version` header.
@@ -2080,7 +2079,7 @@ pub(crate) fn parse_message_id(ids: &str) -> Result<String> {
if let Some(id) = parse_message_ids(ids).first() {
Ok(id.to_string())
} else {
bail!("could not parse message_id: {}", ids);
bail!("could not parse message_id: {ids}");
}
}
@@ -2433,9 +2432,9 @@ async fn handle_ndn(
// The NDN might be for a message-id that had attachments and was sent from a non-Delta Chat client.
// In this case we need to mark multiple "msgids" as failed that all refer to the same message-id.
let msgs: Vec<_> = context
let msg_ids = context
.sql
.query_map(
.query_map_vec(
"SELECT id FROM msgs
WHERE rfc724_mid=? AND from_id=1",
(&failed.rfc724_mid,),
@@ -2443,7 +2442,6 @@ async fn handle_ndn(
let msg_id: MsgId = row.get(0)?;
Ok(msg_id)
},
|rows| Ok(rows.collect::<Vec<_>>()),
)
.await?;
@@ -2454,8 +2452,7 @@ async fn handle_ndn(
};
let err_msg = &error;
for msg in msgs {
let msg_id = msg?;
for msg_id in msg_ids {
let mut message = Message::load_from_db(context, msg_id).await?;
let aggregated_error = message
.error

View File

@@ -1817,39 +1817,6 @@ async fn test_take_last_header() {
);
}
async fn test_protect_autocrypt(enabled: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
alice
.set_config_bool(Config::ProtectAutocrypt, enabled)
.await?;
let sent = alice.send_text(chat.id, "Hello!").await;
assert_eq!(sent.payload().contains("Autocrypt: "), !enabled);
let msg = bob.recv_msg(&sent).await;
assert_eq!(msg.get_showpadlock(), true);
Ok(())
}
/// Tests that if `protect_autocrypt` is enabled,
/// `Autocrypt` header does not appear in the outer headers
/// of encrypted messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_protect_autocrypt_enabled() -> Result<()> {
test_protect_autocrypt(true).await
}
/// Tests that if `protect_autocrypt` is disabled,
/// `Autocrypt` header appears in the outer headers
/// of encrypted messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_protect_autocrypt_false() -> Result<()> {
test_protect_autocrypt(false).await
}
/// Tests that CRLF before MIME boundary
/// is not treated as the part body.
///

View File

@@ -12,6 +12,7 @@ use tokio_io_timeout::TimeoutStream;
use crate::context::Context;
use crate::net::session::SessionStream;
use crate::net::tls::TlsSessionStore;
use crate::sql::Sql;
use crate::tools::time;
@@ -127,10 +128,19 @@ pub(crate) async fn connect_tls_inner(
addr: SocketAddr,
host: &str,
strict_tls: bool,
alpn: &[&str],
alpn: &str,
tls_session_store: &TlsSessionStore,
) -> Result<impl SessionStream + 'static> {
let tcp_stream = connect_tcp_inner(addr).await?;
let tls_stream = wrap_tls(strict_tls, host, alpn, tcp_stream).await?;
let tls_stream = wrap_tls(
strict_tls,
host,
addr.port(),
alpn,
tcp_stream,
tls_session_store,
)
.await?;
Ok(tls_stream)
}

View File

@@ -630,7 +630,7 @@ async fn lookup_cache(
let mut res = Vec::new();
for cached_address in context
.sql
.query_map(
.query_map_vec(
"SELECT dns_cache.address
FROM dns_cache
LEFT JOIN connection_history
@@ -647,10 +647,6 @@ async fn lookup_cache(
let address: String = row.get(0)?;
Ok(address)
},
|rows| {
rows.collect::<std::result::Result<Vec<String>, _>>()
.map_err(Into::into)
},
)
.await?
{

View File

@@ -16,6 +16,10 @@ use crate::net::session::SessionStream;
use crate::net::tls::wrap_rustls;
use crate::tools::time;
/// User-Agent for HTTP requests if a resource usage policy requires it.
/// By default we do not set User-Agent.
const USER_AGENT: &str = "chatmail/2 (+https://github.com/chatmail/core/)";
/// HTTP(S) GET response.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
@@ -76,11 +80,13 @@ where
let proxy_stream = proxy_config
.connect(context, host, port, load_cache)
.await?;
let tls_stream = wrap_rustls(host, &[], proxy_stream).await?;
let tls_stream =
wrap_rustls(host, port, "", proxy_stream, &context.tls_session_store).await?;
Box::new(tls_stream)
} else {
let tcp_stream = crate::net::connect_tcp(context, host, port, load_cache).await?;
let tls_stream = wrap_rustls(host, &[], tcp_stream).await?;
let tls_stream =
wrap_rustls(host, port, "", tcp_stream, &context.tls_session_store).await?;
Box::new(tls_stream)
}
}
@@ -102,6 +108,13 @@ fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
let stale = if url.ends_with(".xdc") {
// WebXDCs are never stale, they just expire.
expires
} else if url.starts_with("https://tile.openstreetmap.org/")
|| url.starts_with("https://vector.openstreetmap.org/")
{
// Policy at <https://operations.osmfoundation.org/policies/tiles/>
// requires that we cache tiles for at least 7 days.
// Do not revalidate earlier than that.
now + 3600 * 24 * 7
} else if mimetype.is_some_and(|s| s.starts_with("image/")) {
// Cache images for 1 day.
//
@@ -243,8 +256,22 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
.context("URL has no authority")?
.clone();
let req = hyper::Request::builder()
.uri(parsed_url)
let req = hyper::Request::builder().uri(parsed_url);
// OSM usage policy requires
// that User-Agent is set for HTTP GET requests
// to tile servers:
// <https://operations.osmfoundation.org/policies/tiles/>
// Same for vectory tiles
// at <https://operations.osmfoundation.org/policies/vector/>.
let req =
if authority == "tile.openstreetmap.org" || authority == "vector.openstreetmap.org" {
req.header("User-Agent", USER_AGENT)
} else {
req
};
let req = req
.header(hyper::header::HOST, authority.as_str())
.body(http_body_util::Empty::<Bytes>::new())?;
let response = sender.send_request(req).await?;

View File

@@ -429,7 +429,14 @@ impl ProxyConfig {
load_cache,
)
.await?;
let tls_stream = wrap_rustls(&https_config.host, &[], tcp_stream).await?;
let tls_stream = wrap_rustls(
&https_config.host,
https_config.port,
"",
tcp_stream,
&context.tls_session_store,
)
.await?;
let auth = if let Some((username, password)) = &https_config.user_password {
Some((username.as_str(), password.as_str()))
} else {

View File

@@ -1,27 +1,38 @@
//! TLS support.
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Result;
use crate::net::session::SessionStream;
use tokio_rustls::rustls::client::ClientSessionStore;
pub async fn wrap_tls<'a>(
strict_tls: bool,
hostname: &str,
alpn: &[&str],
port: u16,
alpn: &str,
stream: impl SessionStream + 'static,
tls_session_store: &TlsSessionStore,
) -> Result<impl SessionStream + 'a> {
if strict_tls {
let tls_stream = wrap_rustls(hostname, alpn, stream).await?;
let tls_stream = wrap_rustls(hostname, port, alpn, stream, tls_session_store).await?;
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
Ok(boxed_stream)
} else {
// We use native_tls because it accepts 1024-bit RSA keys.
// Rustls does not support them even if
// certificate checks are disabled: <https://github.com/rustls/rustls/issues/234>.
let alpns = if alpn.is_empty() {
Box::from([])
} else {
Box::from([alpn])
};
let tls = async_native_tls::TlsConnector::new()
.min_protocol_version(Some(async_native_tls::Protocol::Tlsv12))
.request_alpns(alpn)
.request_alpns(&alpns)
.danger_accept_invalid_hostnames(true)
.danger_accept_invalid_certs(true);
let tls_stream = tls.connect(hostname, stream).await?;
@@ -30,18 +41,82 @@ pub async fn wrap_tls<'a>(
}
}
/// Map to store TLS session tickets.
///
/// Tickets are separated by port and ALPN
/// to avoid trying to use Postfix ticket for Dovecot and vice versa.
/// Doing so would not be a security issue,
/// but wastes the ticket and the opportunity to resume TLS session unnecessarily.
/// Rustls takes care of separating tickets that belong to different domain names.
#[derive(Debug)]
pub(crate) struct TlsSessionStore {
sessions: Mutex<HashMap<(u16, String), Arc<dyn ClientSessionStore>>>,
}
// This is the default for TLS session store
// as of Rustls version 0.23.16,
// but we want to create multiple caches
// to separate them by port and ALPN.
const TLS_CACHE_SIZE: usize = 256;
impl TlsSessionStore {
/// Creates a new TLS session store.
///
/// One such store should be created per profile
/// to keep TLS sessions independent.
pub fn new() -> Self {
Self {
sessions: Default::default(),
}
}
/// Returns session store for given port and ALPN.
///
/// Rustls additionally separates sessions by hostname.
pub fn get(&self, port: u16, alpn: &str) -> Arc<dyn ClientSessionStore> {
Arc::clone(
self.sessions
.lock()
.entry((port, alpn.to_string()))
.or_insert_with(|| {
Arc::new(tokio_rustls::rustls::client::ClientSessionMemoryCache::new(
TLS_CACHE_SIZE,
))
}),
)
}
}
pub async fn wrap_rustls<'a>(
hostname: &str,
alpn: &[&str],
port: u16,
alpn: &str,
stream: impl SessionStream + 'a,
tls_session_store: &TlsSessionStore,
) -> Result<impl SessionStream + 'a> {
let mut root_cert_store = rustls::RootCertStore::empty();
let mut root_cert_store = tokio_rustls::rustls::RootCertStore::empty();
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config = rustls::ClientConfig::builder()
let mut config = tokio_rustls::rustls::ClientConfig::builder()
.with_root_certificates(root_cert_store)
.with_no_client_auth();
config.alpn_protocols = alpn.iter().map(|s| s.as_bytes().to_vec()).collect();
config.alpn_protocols = if alpn.is_empty() {
vec![]
} else {
vec![alpn.as_bytes().to_vec()]
};
// Enable TLS 1.3 session resumption
// as defined in <https://www.rfc-editor.org/rfc/rfc8446#section-2.2>.
//
// Obsolete TLS 1.2 mechanisms defined in RFC 5246
// and RFC 5077 have worse security
// and are not worth increasing
// attack surface: <https://words.filippo.io/we-need-to-talk-about-session-tickets/>.
let resumption_store = tls_session_store.get(port, alpn);
let resumption = tokio_rustls::rustls::client::Resumption::store(resumption_store)
.tls12_resumption(tokio_rustls::rustls::client::Tls12Resumption::Disabled);
config.resumption = resumption;
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
let name = rustls_pki_types::ServerName::try_from(hostname)?.to_owned();

View File

@@ -14,17 +14,6 @@ use crate::provider;
use crate::provider::Oauth2Authorizer;
use crate::tools::time;
const OAUTH2_GMAIL: Oauth2 = Oauth2 {
// see <https://developers.google.com/identity/protocols/OAuth2InstalledApp>
client_id: "959970109878-4mvtgf6feshskf7695nfln6002mom908.apps.googleusercontent.com",
get_code: "https://accounts.google.com/o/oauth2/auth?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&response_type=code&scope=https%3A%2F%2Fmail.google.com%2F%20email&access_type=offline",
init_token: "https://accounts.google.com/o/oauth2/token?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&code=$CODE&grant_type=authorization_code",
refresh_token: "https://accounts.google.com/o/oauth2/token?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&refresh_token=$REFRESH_TOKEN&grant_type=refresh_token",
get_userinfo: Some(
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json&access_token=$ACCESS_TOKEN",
),
};
const OAUTH2_YANDEX: Oauth2 = Oauth2 {
// see <https://tech.yandex.com/oauth/doc/dg/reference/auto-code-client-docpage/>
client_id: "c4d0b6735fc8420a816d7e1303469341",
@@ -64,7 +53,7 @@ pub async fn get_oauth2_url(
addr: &str,
redirect_uri: &str,
) -> Result<Option<String>> {
if let Some(oauth2) = Oauth2::from_address(context, addr).await {
if let Some(oauth2) = Oauth2::from_address(addr) {
context
.sql
.set_raw_config("oauth2_pending_redirect_uri", Some(redirect_uri))
@@ -84,7 +73,7 @@ pub(crate) async fn get_oauth2_access_token(
code: &str,
regenerate: bool,
) -> Result<Option<String>> {
if let Some(oauth2) = Oauth2::from_address(context, addr).await {
if let Some(oauth2) = Oauth2::from_address(addr) {
let lock = context.oauth2_mutex.lock().await;
// read generated token
@@ -232,7 +221,7 @@ pub(crate) async fn get_oauth2_addr(
addr: &str,
code: &str,
) -> Result<Option<String>> {
let oauth2 = match Oauth2::from_address(context, addr).await {
let oauth2 = match Oauth2::from_address(addr) {
Some(o) => o,
None => return Ok(None),
};
@@ -267,19 +256,16 @@ pub(crate) async fn get_oauth2_addr(
}
impl Oauth2 {
async fn from_address(context: &Context, addr: &str) -> Option<Self> {
fn from_address(addr: &str) -> Option<Self> {
let addr_normalized = normalize_addr(addr);
let skip_mx = true;
if let Some(domain) = addr_normalized
.find('@')
.map(|index| addr_normalized.split_at(index + 1).1)
{
if let Some(oauth2_authorizer) = provider::get_provider_info(context, domain, skip_mx)
.await
if let Some(oauth2_authorizer) = provider::get_provider_info(domain)
.and_then(|provider| provider.oauth2_authorizer.as_ref())
{
return Some(match oauth2_authorizer {
Oauth2Authorizer::Gmail => OAUTH2_GMAIL,
Oauth2Authorizer::Yandex => OAUTH2_YANDEX,
});
}
@@ -366,21 +352,16 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_oauth_from_address() {
let t = TestContext::new().await;
// Delta Chat does not have working Gmail client ID anymore.
assert_eq!(Oauth2::from_address(&t, "hello@gmail.com").await, None);
assert_eq!(Oauth2::from_address(&t, "hello@googlemail.com").await, None);
assert_eq!(Oauth2::from_address("hello@gmail.com"), None);
assert_eq!(Oauth2::from_address("hello@googlemail.com"), None);
assert_eq!(
Oauth2::from_address(&t, "hello@yandex.com").await,
Oauth2::from_address("hello@yandex.com"),
Some(OAUTH2_YANDEX)
);
assert_eq!(
Oauth2::from_address(&t, "hello@yandex.ru").await,
Some(OAUTH2_YANDEX)
);
assert_eq!(Oauth2::from_address(&t, "hello@web.de").await, None);
assert_eq!(Oauth2::from_address("hello@yandex.ru"), Some(OAUTH2_YANDEX));
assert_eq!(Oauth2::from_address("hello@web.de"), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

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