Compare commits

..

31 Commits

Author SHA1 Message Date
iequidoo
974e32dd76 fix: Don't decrease member add/remove timestamps if they aren't far away in the future
We shouldn't decrease `add_timestamp` and `remove_timestamp` in the `chats_contacts` table normally,
even if remote changes arrive reordered. This particularly makes sense for ad-hoc groups (see
`chat::update_chat_contacts_table()` in `apply_group_changes()`) and in case if we join an encrypted
group which we were a member of before (see `chat::add_to_chat_contacts_table()` call).

Still, limit already stored timestamps in case local clock was in the future and is set back
now. But our clock may be slow, so limit stored timestamps with a remote timestamp if it's
bigger.

NB: `receive_imf::update_chats_contacts_timestamps()` already only increases timestamps, but it's
used only for handling of the "Chat-Group-Member-Timestamps" header, i.e. for encrypted groups.
2026-06-07 21:45:47 -03:00
iequidoo
c91608e9f1 fix: Don't send removal message to contact that hasn't been a chat member (#8298)
I.e. don't fail `remove_contact_from_chat()` for such a contact because there may be a race
condition with a remote removal of the contact done without trace, but don't send a removal message
and sync message in this case and don't emit a `ChatModified` event.

If a contact is already a past member, we still send a removal message to the chat, this is safe and
protects from lost removal messages, so there's no need to complicate the code in this case.
2026-06-07 13:23:16 -03:00
link2xt
207c2e6e4c feat: switch to aws-lc-rs cryptography provider for Rustls
aws-lc-rs is the default provider for Rustls.
We have been using ring as the default provider
to avoid duplicate dependency
because iroh supports only ring in 0.35.0,
but with version 1.0 it will be possible
to select other crypto providers
and we can switch to using more common default.

This change also enables "tls12" feature explicitly,
otherwise if there is no other dependency
requiring it, only TLS 1.3 will be supported
and we want to keep supporting TLS 1.2.
2026-06-06 09:56:31 +00:00
dependabot[bot]
c0705a8d92 chore(deps): bump taiki-e/install-action from 2.79.2 to 2.79.10
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.79.2 to 2.79.10.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](213ccc1a07...60ae4ce63c)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 19:55:34 +00:00
dependabot[bot]
18ce5a02cc chore(deps): bump EmbarkStudios/cargo-deny-action from 2.0.18 to 2.0.19
Bumps [EmbarkStudios/cargo-deny-action](https://github.com/embarkstudios/cargo-deny-action) from 2.0.18 to 2.0.19.
- [Release notes](https://github.com/embarkstudios/cargo-deny-action/releases)
- [Commits](6c8f9facfa...a531616d8c)

---
updated-dependencies:
- dependency-name: EmbarkStudios/cargo-deny-action
  dependency-version: 2.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 19:55:13 +00:00
dependabot[bot]
e87e269f98 chore(cargo): bump serde_json from 1.0.149 to 1.0.150
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.150.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 19:54:21 +00:00
dependabot[bot]
4bb557cf53 chore(cargo): bump log from 0.4.29 to 0.4.30
Bumps [log](https://github.com/rust-lang/log) from 0.4.29 to 0.4.30.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.29...0.4.30)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 19:54:02 +00:00
dependabot[bot]
9b4503e3f5 chore(cargo): bump tokio from 1.52.1 to 1.52.3
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 19:53:16 +00:00
dependabot[bot]
4428382433 chore(cargo): bump pin-project from 1.1.11 to 1.1.13
Bumps [pin-project](https://github.com/taiki-e/pin-project) from 1.1.11 to 1.1.13.
- [Release notes](https://github.com/taiki-e/pin-project/releases)
- [Changelog](https://github.com/taiki-e/pin-project/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/pin-project/compare/v1.1.11...v1.1.13)

---
updated-dependencies:
- dependency-name: pin-project
  dependency-version: 1.1.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 15:24:20 +00:00
Hocuri
8c56b63f21 feat: Add number_of_keys to statistics (#8297)
As of 3b29469102, all public keys except
for the currently-used one are ignored. But Delta Chat still tries to
use all _private_ keys for decryption.

This PR adds the information how many keys there are in the database. If
there is more than one, we know that the user imported their own key
back when that was possible. This info is interesting both for ourselves
and for the Cispa researchers that want to write a paper about Delta
Chat.

An alternative to this PR would be to make `key_create_timestamps`
actually contain the full list of key creation timestamps. I'm not sure
if that's worth the effort, though.

Follow-up to #8293
2026-06-03 12:36:31 +02:00
Hocuri
50e83f2072 feat: Add cryptography-related statistics (#8293)
This adds `number_of_transports`, `key_version`, `key_algorithm`,
`pubkey_size`.
2026-06-02 10:26:53 +02:00
link2xt
4a94a34c6d chore: update preloaded DNS cache 2026-06-01 11:29:25 +00:00
Hocuri
bd6c9908e4 fix: Update the channel title after joining if the QR code included a wrong title (#8260)
Fix https://github.com/chatmail/core/issues/8250. Not sure why anyone
would tamper with the link, but maybe the name gets ellipsized because
it is very long, or maybe we decide to ellipsize more aggressively in
the future.

The fix is easy enough, just set the GroupNameTimestamp when creating
the channel, so that `HeaderDef::ChatGroupNameTimestamp` gets set when
sending a message and the logic in
`apply_chat_name_avatar_and_description_changes()` recognizes the
incoming name's timestamp as newer than `chat_group_name_timestamp`
(which is 0 right after joining a chat).

The test is somewhat hacky and depends on the title being the last
parameter in the invite code URL, but this is fine; if we ever change
that, then the test will fail and we need to modify it.

This also removes some wrong doc comments.

Since groups are unpromoted until sending a first message, the same fix isn't needed for groups.
2026-06-01 08:48:01 +00:00
iequidoo
dd5ec9621b feat: Add IMAP folder to Context::get_info() (#8285)
Note that the default value "INBOX" isn't shown however.
2026-05-31 19:59:31 -03:00
link2xt
92d522473f refactor: remove support for building "source" packages for deltachat-rpc-server
This hack was needed to "build" Android packages
by repackaging Linux packages on-the-fly,
but PyPI has since added Android support
and we are using it directly.
2026-05-31 08:45:39 +00:00
iequidoo
049fd4f355 refactor: Remove misplaced comment
It was added in ff99e1997 "inital commit" and even there it looks misplaced.
2026-05-29 15:39:15 -03:00
link2xt
7ea637c930 chore: bump version to 2.51.0-dev 2026-05-29 18:32:58 +02:00
link2xt
3a1b0d4679 chore(release): prepare for 2.51.0 2026-05-29 18:05:43 +02:00
WofWca
7a60c79301 feat: message info: add database msg ID
We have recently removed it from the normal "message info" dialog
at least on Delta Chat Desktop, so without this one can't easily find
the ID of a particular message,
which is useful for debugging.
2026-05-29 15:33:14 +04:00
link2xt
0bc7849e8a build(nix): fix downloads from crates.io in nix builds
crates.io recently started rejecting requests to https://crates.io/api/v1/crates
made by Nix: https://github.com/rust-lang/crates.io/issues/13482
This resulted in failing to download any dependencies
from crates.io with 403 error during Rust package builds.
The problem was solved in nixpkgs by switching
to CDN URL https://static.crates.io/crates
in https://github.com/NixOS/nixpkgs/pull/524985

As of writing this on 2026-05-29
the fix is on "master" branch of nixpkgs,
but not on "nixos-unstable" or "nixpkgs-unstable"
branches yet according to https://nixpk.gs/pr-tracker.html?pr=524985
so I have switched nixpkgs to "master" branch to get the problem fixed.

naersk needs a similar fix. I opened a PR for it
https://github.com/nix-community/naersk/pull/391
but because the fix is not merged yet,
switched to my PR branch in flake.nix.

Updating nixpkgs required some minor changes,
e.g. sphinx_rtd_theme has been renamed to sphinx-rtd-theme,
pthreads in Windows builds had to be moved to buildInputs to fix
"Refusing to evaluate package 'mingw_w64-pthreads-13.0.0'
in /nix/store/f78lkqnk63pd0kf52zf2wcx35p1nnalr-source/pkgs/os-specific/windows/mingw-w64/headers.nix:35
because it is not available on the requested hostPlatform"
2026-05-29 10:16:16 +00:00
link2xt
dfea8b0134 test: test encrypted messages in test_delete_expired_imap_messages 2026-05-29 08:37:21 +00:00
link2xt
376c819374 feat: immediately remove all encrypted messages from the server in single-device mode 2026-05-29 08:37:21 +00:00
link2xt
02827406f3 docs: fix reference in delete_expired_imap_messages comment
It should refer to delete_expired_messages,
not to itself.
2026-05-29 08:37:21 +00:00
link2xt
9d9f61d9eb refactor: make should_delete_all_downloaded_messages non-async
Pass bcc_self into it as a boolean.
2026-05-29 08:37:21 +00:00
link2xt
a2816d7bd3 test: test bcc_self in test_delete_expired_imap_messages 2026-05-29 08:37:21 +00:00
iequidoo
9719aa1415 fix: Don't make message OutDelivered after successful resending to new broadcast member
Follow-up to 970222f. We shouldn't update the message state to `OutDelivered` if message sending
failed before and the message was re-sent successfully only to a new broadcast member.
2026-05-29 00:35:46 -03:00
Tom Niget
5733a783fb feat: follow certificate check parameter in autoconfig 2026-05-28 20:18:40 +00:00
holger krekel
c26b6a017c fix: syntax error in older migration 2026-05-27 06:20:50 +00:00
dependabot[bot]
bb6a478430 chore(deps): bump taiki-e/install-action from 2.78.1 to 2.79.2
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.78.1 to 2.79.2.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](184183c240...213ccc1a07)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 05:27:42 +00:00
link2xt
480248a168 refactor: remove pre_encrypt_mime_hook
It is only used in a single test
and the test is more reliable by having a blob .eml
that will not be accidentally modified
by mimefactory changes.
2026-05-26 08:10:51 +00:00
dependabot[bot]
627f98915d chore(deps): bump zizmorcore/zizmor-action from 0.5.3 to 0.5.6
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.3 to 0.5.6.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](b1d7e1fb5d...5f14fd08f7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-26 05:06:42 +00:00
41 changed files with 760 additions and 467 deletions

View File

@@ -62,7 +62,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1
- uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823
with:
arguments: --workspace --all-features --locked
command: check
@@ -146,7 +146,7 @@ jobs:
cache-bin: false
- name: Install nextest
uses: taiki-e/install-action@184183c2401be73c3bf42c2e61268aa5855379c1
uses: taiki-e/install-action@60ae4ce63c7aeb6e96d7f572c1ec7fafbb17ca80
with:
tool: nextest

View File

@@ -63,7 +63,6 @@ jobs:
- deltachat-rpc-server-armv7l-linux-wheel
- deltachat-rpc-server-i686-linux
- deltachat-rpc-server-i686-linux-wheel
- deltachat-rpc-server-source
- deltachat-rpc-server-win32
- deltachat-rpc-server-win32-wheel
- deltachat-rpc-server-win64

View File

@@ -23,4 +23,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6

View File

@@ -1,5 +1,44 @@
# Changelog
## [2.51.0] - 2026-05-29
### Features / Changes
- Follow certificate check parameter in autoconfig.
- Immediately remove all encrypted messages from the server in single-device mode.
### Fixes
- Fix syntax error in `only_fetch_mvbox` migration 150 resulting in failure to upgrade for `only_fetch_mvbox` users.
- Do not try to resolve proxy IPv6 addresses in square brackets.
- Do not fail to receive post-message with status updates for deleted webxdc.
- Don't make message `OutDelivered` after successful resending to new broadcast member.
### Build system
- nix: fix downloads from crates.io in nix builds.
### Documentation
- Fix reference in `delete_expired_imap_messages` comment.
### Refactor
- Remove `pre_encrypt_mime_hook`.
- Make `should_delete_all_downloaded_messages` non-async.
### Tests
- Test IPv6 addresses in HTTP(S) proxies.
- Test `bcc_self` in `test_delete_expired_imap_messages`.
- Test encrypted messages in `test_delete_expired_imap_messages`.
### Miscellaneous Tasks
- Bump version to 2.51.0-dev.
- deps: bump zizmorcore/zizmor-action from 0.5.3 to 0.5.6.
- deps: bump taiki-e/install-action from 2.78.1 to 2.79.2.
## [2.50.0] - 2026-05-22
### API-Changes
@@ -8257,3 +8296,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.48.0]: https://github.com/chatmail/core/compare/v2.47.0..v2.48.0
[2.49.0]: https://github.com/chatmail/core/compare/v2.48.0..v2.49.0
[2.50.0]: https://github.com/chatmail/core/compare/v2.49.0..v2.50.0
[2.51.0]: https://github.com/chatmail/core/compare/v2.50.0..v2.51.0

102
Cargo.lock generated
View File

@@ -391,6 +391,28 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "backon"
version = "1.5.0"
@@ -763,10 +785,13 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.14"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -920,6 +945,15 @@ dependencies = [
"digest",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.2.3"
@@ -1676,7 +1710,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.1",
"windows-sys 0.59.0",
]
[[package]]
@@ -1726,6 +1760,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.18"
@@ -2051,6 +2091,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -2108,6 +2154,12 @@ dependencies = [
name = "format-flowed"
version = "1.0.0"
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "funty"
version = "2.0.0"
@@ -2681,7 +2733,7 @@ dependencies = [
"hyper",
"libc",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.9",
"tokio",
"tower-service",
"tracing",
@@ -3234,6 +3286,16 @@ version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -3374,9 +3436,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
[[package]]
name = "loom"
@@ -3818,7 +3880,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.1",
"windows-sys 0.59.0",
]
[[package]]
@@ -4303,18 +4365,18 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.1.11"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
@@ -5219,7 +5281,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.1",
"windows-sys 0.52.0",
]
[[package]]
@@ -5228,6 +5290,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@@ -5273,6 +5336,7 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -5517,9 +5581,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -5695,9 +5759,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
@@ -6083,7 +6147,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.1",
"windows-sys 0.52.0",
]
[[package]]
@@ -6231,9 +6295,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",

View File

@@ -101,7 +101,7 @@ tagger = "4.3.4"
textwrap = "0.16.2"
thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-rustls = { version = "0.26.2", default-features = false, features = ["aws-lc-rs", "tls12"] }
tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.6.2", default-features = false }
tokio-util = { workspace = true }

View File

@@ -1106,9 +1106,6 @@ impl CommandApi {
/// because the word "channel" already appears a lot in the code,
/// which would make it hard to grep for it.
///
/// After creation, the chat contains no recipients and is in _unpromoted_ state;
/// see [`CommandApi::create_group_chat`] for more information on the unpromoted state.
///
/// Returns the created chat's id.
async fn create_broadcast(&self, account_id: u32, chat_name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;

View File

@@ -340,9 +340,6 @@ class Account:
because the word "channel" already appears a lot in the code,
which would make it hard to grep for it.
After creation, the chat contains no recipients and is in _unpromoted_ state;
see `create_group()` for more information on the unpromoted state.
Returns the created chat.
"""
return Chat(self, self._rpc.create_broadcast(self.id, name))

151
flake.lock generated
View File

@@ -3,15 +3,19 @@
"android": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1731356359,
"narHash": "sha256-vYqJnu6jotmWpPT4DgzHVdvNIZcKZCIUqS8QaptsZA0=",
"lastModified": 1779918845,
"narHash": "sha256-FbpOOBg15L7X6NWWmTKbSdccnH59Jq53wWmAO37d2Q8=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "c028ead7e88edb2e94cd7c90ee37593f63ae494a",
"rev": "105c093afc8c8fbeea98f8e398403f93043eba17",
"type": "github"
},
"original": {
@@ -28,11 +32,11 @@
]
},
"locked": {
"lastModified": 1728330715,
"narHash": "sha256-xRJ2nPOXb//u1jaBnDP56M7v5ldavjbtR6lfGqSvcKg=",
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "dd6b80932022cea34a019e2bb32f6fa9e494dfef",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
@@ -43,15 +47,17 @@
},
"fenix": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1763361733,
"narHash": "sha256-ka7dpwH3HIXCyD2wl5F7cPLeRbqZoY2ullALsvxdPt8=",
"lastModified": 1779876442,
"narHash": "sha256-O25HomVNmdROO13PEQ3Ran8Hq5EsyLmVn8Gb8JvJtJE=",
"owner": "nix-community",
"repo": "fenix",
"rev": "6c8d48e3b0ae371b19ac1485744687b788e80193",
"rev": "2eff81fc84390a35e1565395ae945d9394856824",
"type": "github"
},
"original": {
@@ -65,29 +71,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@@ -98,29 +86,35 @@
},
"naersk": {
"inputs": {
"nixpkgs": "nixpkgs_3"
"fenix": [
"fenix"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1721727458,
"narHash": "sha256-r/xppY958gmZ4oTfLiHN0ZGuQ+RSTijDblVgVLFi1mw=",
"lastModified": 1779912356,
"narHash": "sha256-yj5O6vmAj+OfhTQMiUwhmQRP0HAII3BxEI6zuY6h/5k=",
"owner": "nix-community",
"repo": "naersk",
"rev": "3fb418eaf352498f6b6c30592e3beb63df42ef11",
"rev": "33eaf5c72a67db15073322d26cd342c443556214",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "pull/391/head",
"repo": "naersk",
"type": "github"
}
},
"nix-filter": {
"locked": {
"lastModified": 1730207686,
"narHash": "sha256-SCHiL+1f7q9TAnxpasriP6fMarWE5H43t25F5/9e28I=",
"lastModified": 1757882181,
"narHash": "sha256-+cCxYIh2UNalTz364p+QYmWHs0P+6wDhiWR4jDIKQIU=",
"owner": "numtide",
"repo": "nix-filter",
"rev": "776e68c1d014c3adde193a18db9d738458cd2ba4",
"rev": "59c44d1909c72441144b93cf0f054be7fe764de5",
"type": "github"
},
"original": {
@@ -131,60 +125,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1731139594,
"narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "76612b17c0ce71689921ca12d9ffdc9c23ce40b2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1762977756,
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
"lastModified": 1779931091,
"narHash": "sha256-gc8NEz7a++7OQPGvMv+zIjXCec1PO38XRXZRa3m97ew=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
"rev": "3052ddf0614791c1869384a868248be5607a309f",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 0,
"narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=",
"path": "/nix/store/zq2axpgzd5kykk1v446rkffj3bxa2m2h-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1747179050,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"ref": "master",
"repo": "nixpkgs",
"type": "github"
}
@@ -193,20 +143,20 @@
"inputs": {
"android": "android",
"fenix": "fenix",
"flake-utils": "flake-utils_2",
"flake-utils": "flake-utils",
"naersk": "naersk",
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs_4"
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1762860488,
"narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=",
"lastModified": 1779827300,
"narHash": "sha256-J6pHxKoZzWCrAvOVInwBcYYWix/NWwM10Ad+i29Qc5s=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "2efc80078029894eec0699f62ec8d5c1a56af763",
"rev": "c3af07ad84d68adc5e652e86f0c20009caa29014",
"type": "github"
},
"original": {
@@ -230,21 +180,6 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@@ -2,11 +2,16 @@
description = "Chatmail core";
inputs = {
fenix.url = "github:nix-community/fenix";
fenix.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk";
naersk.url = "github:nix-community/naersk/pull/391/head";
naersk.inputs.nixpkgs.follows = "nixpkgs";
naersk.inputs.fenix.follows = "fenix";
nix-filter.url = "github:numtide/nix-filter";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
nixpkgs.url = "github:nixos/nixpkgs/master";
android.url = "github:tadfisher/android-nixpkgs";
android.inputs.nixpkgs.follows = "nixpkgs";
android.inputs.flake-utils.follows = "flake-utils";
};
outputs = { self, nixpkgs, flake-utils, nix-filter, naersk, fenix, android }:
flake-utils.lib.eachDefaultSystem (system:
@@ -133,6 +138,8 @@
];
depsBuildBuild = [
pkgsWin64.stdenv.cc
];
buildInputs = [
pkgsWin64.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
@@ -143,6 +150,8 @@
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
"-L"
"native=${pkgsWin64.windows.pthreads}/lib"
];
CC = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
@@ -180,7 +189,8 @@
};
})).overrideAttrs (oldAttr: {
configureFlags = oldAttr.configureFlags ++ [
"--disable-sjlj-exceptions --with-dwarf2"
"--disable-sjlj-exceptions"
"--with-dwarf2"
];
})
);
@@ -196,6 +206,8 @@
];
depsBuildBuild = [
winCC
];
buildInputs = [
pkgsWin32.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
@@ -206,6 +218,8 @@
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
"-L"
"native=${pkgsWin32.windows.pthreads}/lib"
];
CC = "${winCC}/bin/${winCC.targetPrefix}cc";
@@ -504,22 +518,6 @@
'';
};
# Source package for deltachat-rpc-server.
# Fake package that downloads Linux version,
# needed to install deltachat-rpc-server on Android with `pip`.
deltachat-rpc-server-source =
pkgs.stdenv.mkDerivation {
pname = "deltachat-rpc-server-source";
version = manifest.version;
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [
pkgs.python3
pkgs.python3Packages.wheel
];
buildPhase = ''python3 scripts/wheel-rpc-server.py source deltachat_rpc_server-${manifest.version}.tar.gz'';
installPhase = ''mkdir -p $out; cp -av deltachat_rpc_server-${manifest.version}.tar.gz $out'';
};
deltachat-rpc-client =
pkgs.python3Packages.buildPythonPackage {
pname = "deltachat-rpc-client";
@@ -562,7 +560,7 @@
deltachat-python
deltachat-rpc-client
pkgs.python3Packages.breathe
pkgs.python3Packages.sphinx_rtd_theme
pkgs.python3Packages.sphinx-rtd-theme
];
nativeBuildInputs = [ pkgs.sphinx ];
buildPhase = ''sphinx-build -b html -a python/doc/ dist/html'';

View File

@@ -1 +1 @@
2026-05-22
2026-05-29

View File

@@ -20,86 +20,6 @@ Description-Content-Type: text/markdown
"""
def build_source_package(version, filename):
with tarfile.open(filename, "w:gz") as pkg:
def pack(name, contents):
contents = contents.encode()
tar_info = tarfile.TarInfo(f"deltachat_rpc_server-{version}/{name}")
tar_info.mode = 0o644
tar_info.size = len(contents)
pkg.addfile(tar_info, BytesIO(contents))
pack("PKG-INFO", metadata_contents(version))
pack(
"pyproject.toml",
f"""[build-system]
requires = ["setuptools==68.2.2", "pip"]
build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-server"
version = "{version}"
[project.scripts]
deltachat-rpc-server = "deltachat_rpc_server:main"
""",
)
pack(
"setup.py",
f"""
import sys
from setuptools import setup, find_packages
from distutils.cmd import Command
from setuptools.command.install import install
from setuptools.command.build import build
import subprocess
import platform
import tempfile
from zipfile import ZipFile
from pathlib import Path
import shutil
class BuildCommand(build):
def run(self):
tmpdir = tempfile.mkdtemp()
subprocess.run(
[
sys.executable,
"-m",
"pip",
"download",
"--no-input",
"--timeout",
"1000",
"--platform",
"musllinux_1_1_" + platform.machine(),
"--only-binary=:all:",
"deltachat-rpc-server=={version}",
],
cwd=tmpdir,
)
wheel_path = next(Path(tmpdir).glob("*.whl"))
with ZipFile(wheel_path, "r") as wheel:
exe_path = wheel.extract("deltachat_rpc_server/deltachat-rpc-server", "src")
Path(exe_path).chmod(0o700)
wheel.extract("deltachat_rpc_server/__init__.py", "src")
shutil.rmtree(tmpdir)
return super().run()
setup(
cmdclass={{"build": BuildCommand}},
package_data={{"deltachat_rpc_server": ["deltachat-rpc-server"]}},
)
""",
)
pack("src/deltachat_rpc_server/__init__.py", "")
def build_wheel(version, binary, tag, windows=False):
filename = f"deltachat_rpc_server-{version}-{tag}.whl"
@@ -168,23 +88,19 @@ def main():
with Path("Cargo.toml").open("rb") as fp:
cargo_manifest = tomllib.load(fp)
version = cargo_manifest["package"]["version"]
if sys.argv[1] == "source":
filename = f"deltachat_rpc_server-{version}.tar.gz"
build_source_package(version, filename)
else:
arch = sys.argv[1]
executable = sys.argv[2]
tags = arch2tags[arch]
arch = sys.argv[1]
executable = sys.argv[2]
tags = arch2tags[arch]
if arch in ["win32", "win64"]:
build_wheel(
version,
executable,
f"py3-none-{tags}",
windows=True,
)
else:
build_wheel(version, executable, f"py3-none-{tags}")
if arch in ["win32", "win64"]:
build_wheel(
version,
executable,
f"py3-none-{tags}",
windows=True,
)
else:
build_wheel(version, executable, f"py3-none-{tags}")
main()

View File

@@ -32,6 +32,7 @@ use crate::debug_logging::maybe_set_logging_xdc;
use crate::download::{
DownloadState, PRE_MSG_ATTACHMENT_SIZE_THRESHOLD, PRE_MSG_SIZE_WARNING_THRESHOLD,
};
use crate::ensure_and_debug_assert_eq;
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key::{Fingerprint, self_fingerprint};
@@ -1782,9 +1783,8 @@ impl Chat {
);
bail!("Cannot set message, contact for {} not found.", self.id);
}
} else if matches!(self.typ, Chattype::Group | Chattype::OutBroadcast)
&& self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1
{
} else if self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
ensure_and_debug_assert_eq!(self.typ, Chattype::Group,);
msg.param.set_int(Param::AttachChatAvatarAndDescription, 1);
self.param
.remove(Param::Unpromoted)
@@ -3626,9 +3626,6 @@ pub(crate) async fn create_group_ex(
/// because the word "channel" already appears a lot in the code,
/// which would make it hard to grep for it.
///
/// After creation, the chat contains no recipients and is in _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> {
let grpid = create_id();
@@ -3660,17 +3657,20 @@ pub(crate) async fn create_out_broadcast_ex(
|row| row.get(0),
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
let mut params: Params = Params::new();
params.update_timestamp(Param::GroupNameTimestamp, time())?;
t.execute(
"INSERT INTO chats
(type, name, name_normalized, grpid, created_timestamp)
VALUES(?, ?, ?, ?, ?)",
(type, name, name_normalized, grpid, created_timestamp, param)
VALUES(?, ?, ?, ?, ?, ?)",
(
Chattype::OutBroadcast,
&chat_name,
normalize_text(&chat_name),
&grpid,
timestamp,
params.to_string(),
),
)?;
let chat_id = ChatId::new(t.last_insert_rowid().try_into()?);
@@ -3738,17 +3738,19 @@ pub(crate) async fn update_chat_contacts_table(
id: ChatId,
contacts: &BTreeSet<ContactId>,
) -> Result<()> {
// See add_to_chat_contacts_table() for reasoning.
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
context
.sql
.transaction(move |transaction| {
// Bump `remove_timestamp` to at least `now`
// even for members from `contacts`.
// Bump `remove_timestamp` even for members from `contacts`.
// We add members from `contacts` back below.
transaction.execute(
"UPDATE chats_contacts
SET remove_timestamp=MAX(add_timestamp+1, ?)
"UPDATE chats_contacts SET
add_timestamp=MIN(add_timestamp, ?1),
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
WHERE chat_id=?",
(timestamp, id),
(limit, timestamp, id),
)?;
if !contacts.is_empty() {
@@ -3760,9 +3762,8 @@ pub(crate) async fn update_chat_contacts_table(
)?;
for contact_id in contacts {
// We bumped `add_timestamp` for existing rows above,
// so on conflict it is enough to set `add_timestamp = remove_timestamp`
// and this guarantees that `add_timestamp` is no less than `timestamp`.
// We bumped `remove_timestamp` for existing rows above,
// so on conflict it is enough to set `add_timestamp = remove_timestamp`.
statement.execute((id, contact_id, timestamp))?;
}
}
@@ -3779,17 +3780,24 @@ pub(crate) async fn add_to_chat_contacts_table(
chat_id: ChatId,
contact_ids: &[ContactId],
) -> Result<()> {
// Our clock may be slow, so limit stored timestamps with `timestamp` if it's bigger. This way
// we only cap remote timestamps if, in addition, remote changes arrive reordered or we do local
// changes. Also allow some tolerance, moreover, previous removals might lend time from the
// future.
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
context
.sql
.transaction(move |transaction| {
let mut add_statement = transaction.prepare(
"INSERT INTO chats_contacts (chat_id, contact_id, add_timestamp) VALUES(?1, ?2, ?3)
ON CONFLICT (chat_id, contact_id)
DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)",
DO UPDATE SET
remove_timestamp=MIN(remove_timestamp, ?4),
add_timestamp=MIN(MAX(add_timestamp,remove_timestamp,?3), ?4)",
)?;
for contact_id in contact_ids {
add_statement.execute((chat_id, contact_id, timestamp))?;
add_statement.execute((chat_id, contact_id, timestamp, limit))?;
}
Ok(())
})
@@ -3800,26 +3808,34 @@ pub(crate) async fn add_to_chat_contacts_table(
/// Removes a contact from the chat
/// by updating the `remove_timestamp`.
/// Returns whether the contact has been a chat member recently. If so, a removal message should be
/// sent.
pub(crate) async fn remove_from_chat_contacts_table(
context: &Context,
chat_id: ChatId,
contact_id: ContactId,
) -> Result<()> {
) -> Result<bool> {
let now = time();
context
// See add_to_chat_contacts_table() for reasoning.
let limit = now.saturating_add(TIMESTAMP_SENT_TOLERANCE);
let is_past_member = context
.sql
.execute(
"UPDATE chats_contacts
SET remove_timestamp=MAX(add_timestamp+1, ?)
"UPDATE chats_contacts SET
add_timestamp=MIN(add_timestamp, ?1),
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
WHERE chat_id=? AND contact_id=?",
(now, chat_id, contact_id),
(limit, now, chat_id, contact_id),
)
.await?;
Ok(())
.await?
> 0;
Ok(is_past_member)
}
/// Removes a contact from the chat
/// without leaving a trace.
/// without leaving a trace in the db.
/// Returns whether the contact was removed, even if it was a past contact. If so, a removal message
/// should be sent if the removal is issued by this device.
///
/// Note that if we call this function,
/// and then receive a message from another device
@@ -3829,17 +3845,17 @@ pub(crate) async fn remove_from_chat_contacts_table_without_trace(
context: &Context,
chat_id: ChatId,
contact_id: ContactId,
) -> Result<()> {
context
) -> Result<bool> {
let removed = context
.sql
.execute(
"DELETE FROM chats_contacts
WHERE chat_id=? AND contact_id=?",
(chat_id, contact_id),
)
.await?;
Ok(())
.await?
> 0;
Ok(removed)
}
/// Adds a contact to the chat.
@@ -4159,10 +4175,13 @@ pub async fn remove_contact_from_chat(
let mut sync = Nosync;
if chat.is_promoted() && chat.typ != Chattype::OutBroadcast {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
let removed = if chat.is_promoted() && chat.typ != Chattype::OutBroadcast {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?
} else {
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?;
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?
};
if !removed {
return Ok(());
}
// We do not return an error if the contact does not exist in the database.

View File

@@ -9,6 +9,7 @@ use crate::headerdef::HeaderDef;
use crate::imex::{ImexMode, has_backup, imex};
use crate::message::{Message, MessengerMessage, delete_msgs};
use crate::mimeparser::{self, MimeMessage};
use crate::qr::{Qr, check_qr};
use crate::receive_imf::receive_imf;
use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::test_utils;
@@ -2799,6 +2800,30 @@ async fn test_can_send_group() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cant_remove_nonmember() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let alice_broadcast_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_broadcast_id))
.await
.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let alice_charlie_id = alice.add_or_lookup_contact_id(charlie).await;
remove_contact_from_chat(alice, alice_broadcast_id, alice_charlie_id).await?;
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
assert!(!remove_from_chat_contacts_table(alice, alice_broadcast_id, alice_charlie_id).await?);
assert!(
!remove_from_chat_contacts_table_without_trace(alice, alice_broadcast_id, alice_charlie_id)
.await?
);
Ok(())
}
/// Tests that in a broadcast channel,
/// the recipients can't see the identity of their fellow recipients.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -2922,10 +2947,24 @@ async fn test_broadcast_change_name() -> Result<()> {
let fiona = &tcm.fiona().await;
let broadcast_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap();
let mut qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap();
// Something goes wrong with the title, e.g. maybe it gets ellipsized
// Note that the title always comes at the end for human readability
qr += "+modified+title";
{
tcm.section("Alice invites Bob to her channel");
let Qr::AskJoinBroadcast { name, .. } = check_qr(bob, &qr).await? else {
panic!();
};
assert_eq!(name, "Channel modified title");
// The channel's name gets fixed after actually joining the channel:
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.name, "Channel");
}
tcm.section("Alice invites Bob to her channel");
tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.section("Alice invites Fiona to her channel");
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
@@ -3049,6 +3088,31 @@ async fn test_broadcast_resend_to_new_member() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_resend_failed_msg_to_new_member() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_bc_id = create_broadcast(alice, "bc".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_bc_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let alice_msg_id = alice.send_text(alice_bc_id, "text").await.sender_msg_id;
let mut msg = Message::load_from_db(alice, alice_msg_id).await?;
message::set_msg_failed(alice, &mut msg, "error").await?;
let fiona_bc_id = tcm.exec_securejoin_qr(fiona, alice, &qr).await;
let resent_msg = alice.pop_sent_msg().await;
let fiona_msg = fiona.recv_msg(&resent_msg).await;
assert_eq!(fiona_msg.chat_id, fiona_bc_id);
assert_eq!(fiona_msg.text, "text");
assert_eq!(
alice_msg_id.get_state(alice).await?,
MessageState::OutFailed
);
Ok(())
}
/// - Alice has multiple devices
/// - Alice creates a broadcast and sends a message into it
/// - Alice's second device sees the broadcast

View File

@@ -452,11 +452,6 @@ pub enum Config {
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
/// Enables running test hooks, e.g. see `InnerContext::pre_encrypt_mime_hook`.
/// This way is better than conditional compilation, i.e. `#[cfg(test)]`, because tests not
/// using this still run unmodified code.
TestHooks,
/// Return an error from `receive_imf_inner()`. For tests.
SimulateReceiveImfError,

View File

@@ -680,6 +680,8 @@ async fn get_autoconfig(
param: &EnteredLoginParam,
param_domain: &str,
) -> Option<Vec<ServerParams>> {
let accept_invalid_certificates = param.certificate_checks.accept_invalid_certificates();
// Make sure to not encode `.` as `%2E` here.
// Some servers like murena.io on 2024-11-01 produce incorrect autoconfig XML
// when address is encoded.
@@ -696,6 +698,7 @@ async fn get_autoconfig(
"https://autoconfig.{param_domain}/mail/config-v1.1.xml?emailaddress={param_addr_urlencoded}"
),
&param.addr,
accept_invalid_certificates,
)
.await
{
@@ -710,6 +713,7 @@ async fn get_autoconfig(
"https://{param_domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={param_addr_urlencoded}"
),
&param.addr,
accept_invalid_certificates,
)
.await
{
@@ -721,6 +725,7 @@ async fn get_autoconfig(
if let Ok(res) = outlk_autodiscover(
ctx,
format!("https://{param_domain}/autodiscover/autodiscover.xml"),
accept_invalid_certificates,
)
.await
{
@@ -731,6 +736,7 @@ async fn get_autoconfig(
if let Ok(res) = outlk_autodiscover(
ctx,
format!("https://autodiscover.{param_domain}/autodiscover/autodiscover.xml",),
accept_invalid_certificates,
)
.await
{
@@ -743,6 +749,7 @@ async fn get_autoconfig(
ctx,
&format!("https://autoconfig.thunderbird.net/v1.1/{param_domain}"),
&param.addr,
accept_invalid_certificates,
)
.await
{

View File

@@ -10,7 +10,7 @@ use quick_xml::events::{BytesStart, Event};
use super::{Error, ServerParams};
use crate::context::Context;
use crate::log::warn;
use crate::net::read_url;
use crate::net::read_url_with_tls;
use crate::provider::{Protocol, Socket};
#[derive(Debug)]
@@ -249,8 +249,9 @@ pub(crate) async fn moz_autoconfigure(
context: &Context,
url: &str,
addr: &str,
accept_invalid_certificates: bool,
) -> Result<Vec<ServerParams>, Error> {
let xml_raw = read_url(context, url).await?;
let xml_raw = read_url_with_tls(context, url, !accept_invalid_certificates).await?;
let res = parse_serverparams(addr, &xml_raw);
if let Err(err) = &res {

View File

@@ -10,7 +10,7 @@ use quick_xml::events::Event;
use super::{Error, ServerParams};
use crate::context::Context;
use crate::log::warn;
use crate::net::read_url;
use crate::net::read_url_with_tls;
use crate::provider::{Protocol, Socket};
/// Result of parsing a single `Protocol` tag.
@@ -196,10 +196,11 @@ fn protocols_to_serverparams(protocols: Vec<ProtocolTag>) -> Vec<ServerParams> {
pub(crate) async fn outlk_autodiscover(
context: &Context,
mut url: String,
accept_invalid_certificates: bool,
) -> Result<Vec<ServerParams>, Error> {
/* Follow up to 10 xml-redirects (http-redirects are followed in read_url() */
for _i in 0..10 {
let xml_raw = read_url(context, &url).await?;
let xml_raw = read_url_with_tls(context, &url, !accept_invalid_certificates).await?;
let res = parse_xml(&xml_raw);
if let Err(err) = &res {
warn!(context, "{}", err);

View File

@@ -332,17 +332,6 @@ pub struct InnerContext {
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
#[expect(clippy::type_complexity)]
/// Transforms the root of the cryptographic payload before encryption.
pub(crate) pre_encrypt_mime_hook: parking_lot::Mutex<
Option<
for<'a> fn(
&Context,
mail_builder::mime::MimePart<'a>,
) -> mail_builder::mime::MimePart<'a>,
>,
>,
}
/// The state of ongoing process.
@@ -522,7 +511,6 @@ impl Context {
self_fingerprint: OnceLock::new(),
self_public_key: Mutex::new(None),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};
let ctx = Context {

View File

@@ -6,6 +6,7 @@ use anyhow::{Result, anyhow, bail, ensure};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
use crate::imap::session::Session;
use crate::log::warn;
@@ -169,7 +170,8 @@ pub(crate) async fn download_msg(
}
Box::pin(session.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)).await?;
if ephemeral::should_delete_all_downloaded_messages(context, session.is_chatmail()).await? {
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
if ephemeral::should_delete_all_downloaded_messages(bcc_self, session.is_chatmail()) {
// Now that the message was downloaded, it likely needs to be deleted;
// trigger a re-check by interrupting the inbox folder.
// This is mainly needed to make the tests pass;

View File

@@ -654,7 +654,7 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
/// Schedules expired IMAP messages for deletion on the server.
///
/// Also see [`delete_expired_imap_messages`],
/// Also see [`delete_expired_messages`],
/// which locally deletes expired messages.
pub(crate) async fn delete_expired_imap_messages(
context: &Context,
@@ -663,7 +663,8 @@ pub(crate) async fn delete_expired_imap_messages(
) -> Result<()> {
let now = time();
if should_delete_all_downloaded_messages(context, is_chatmail).await? {
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
if should_delete_all_downloaded_messages(bcc_self, is_chatmail) {
// This is the only device using this relay.
// Mark all downloaded messages for deletion, because they are not needed anymore.
//
@@ -690,7 +691,7 @@ pub(crate) async fn delete_expired_imap_messages(
(transport_id, now, DownloadState::Done),
)
.await?;
} else {
} else if bcc_self {
// There may be other devices using this relay,
// either because there is multi-device or because this is a classical email server.
// Only delete expired ephemeral messages.
@@ -711,16 +712,37 @@ pub(crate) async fn delete_expired_imap_messages(
(transport_id, now),
)
.await?;
} else {
// Single device.
// Delete all expired and encrypted messages.
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE transport_id=?1
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
((param GLOB '*\nc=1*' OR param GLOB 'c=1*') AND download_state=?3))
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
(param GLOB '*\nc=1*' OR param GLOB 'c=1*'))
)",
(transport_id, now, DownloadState::Done),
)
.await?;
}
Ok(())
}
pub(crate) async fn should_delete_all_downloaded_messages(
context: &Context,
is_chatmail: bool,
) -> Result<bool> {
Ok(!context.get_config_bool(Config::BccSelf).await? && is_chatmail)
pub(crate) fn should_delete_all_downloaded_messages(bcc_self: bool, is_chatmail: bool) -> bool {
!bcc_self && is_chatmail
}
/// Start ephemeral timers for seen messages if they are not started

View File

@@ -478,9 +478,10 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
// Test messages:
//
// Three messages that were not split into pre- and post- message:
// Four messages that were not split into pre- and post- message:
// "expired@localhost" - expired ephemeral message
// "no_expire@localhost" - non-ephemeral message
// "no_expire_unencrypted@localhost" - non-ephemeral message, not encrypted
// "future@localhost" - will expire in the future, but not yet
//
// And four messages that were split into pre- and post-message.
@@ -489,57 +490,81 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
// "future_*@localhost" - has pre-msg, not expired yet, not downloaded yet
// "done_*@localhost" - Fully downloaded -> post- message can be deleted
//
// The tuple is (rfc724_mid, ephemeral_timestamp, download_state, pre_rfc724_mid)
let msgs: [(&str, i64, DownloadState, &str); 7] = [
("expired@localhost", now - 1, DownloadState::Done, ""),
("no_expire@localhost", 0, DownloadState::Done, ""),
// The tuple is (rfc724_mid, ephemeral_timestamp, download_state, pre_rfc724_mid, is_encrypted)
let msgs: [(&str, i64, DownloadState, &str, bool); 8] = [
("expired@localhost", now - 1, DownloadState::Done, "", true),
("no_expire@localhost", 0, DownloadState::Done, "", true),
(
"no_expire_unencrypted@localhost",
0,
DownloadState::Done,
"",
false,
),
// Use "now + 3600" rather than "now + 1", otherwise the test may be flaky
// if it is slow and the message expires in a second
("future@localhost", now + 3600, DownloadState::Done, ""),
(
"future@localhost",
now + 3600,
DownloadState::Done,
"",
true,
),
(
"expired_post@localhost",
now - 1,
DownloadState::Available,
"expired_pre@localhost",
true,
),
(
"no_expire_post@localhost",
0,
DownloadState::Available,
"no_expire_pre@localhost",
true,
),
(
"future_post@localhost",
now + 3600,
DownloadState::Available,
"future_pre@localhost",
true,
),
(
"done_post@localhost",
0,
DownloadState::Done,
"done_pre@localhost",
true,
),
];
for (rfc724_mid, ephemeral_timestamp, download_state, pre_rfc724_mid) in msgs {
for (rfc724_mid, ephemeral_timestamp, download_state, pre_rfc724_mid, is_encrypted) in msgs {
t.sql
.execute(
"INSERT INTO msgs \
(rfc724_mid, timestamp, ephemeral_timestamp, download_state, pre_rfc724_mid) \
VALUES (?,?,?,?,?)",
(rfc724_mid, timestamp, ephemeral_timestamp, download_state, pre_rfc724_mid, param) \
VALUES (?,?,?,?,?,?)",
(
rfc724_mid,
now,
ephemeral_timestamp,
download_state,
pre_rfc724_mid,
if is_encrypted {
"c=1"
} else {
""
}
),
)
.await?;
}
let rfc724_mids: Vec<&str> = msgs
.iter()
.flat_map(|(rfc724_mid, _, _, pre_rfc724_mid)| [*rfc724_mid, *pre_rfc724_mid])
.flat_map(|(rfc724_mid, _, _, pre_rfc724_mid, _is_encrypted)| {
[*rfc724_mid, *pre_rfc724_mid]
})
.filter(|s| !s.is_empty())
.collect();
@@ -554,13 +579,22 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
.await?;
}
for (is_chatmail, other_transport) in
[(false, false), (false, true), (true, false), (true, true)]
{
for (is_chatmail, other_transport, bcc_self) in [
(false, false, false),
(false, false, true),
(false, true, false),
(false, true, true),
(true, false, false),
(true, false, true),
(true, true, false),
(true, true, true),
] {
println!(
"Testing combination is_chatmail={is_chatmail}, other_transport={other_transport}"
"Testing combination is_chatmail={is_chatmail}, other_transport={other_transport}, bcc_self={bcc_self}"
);
t.set_config_bool(Config::BccSelf, bcc_self).await?;
delete_expired_imap_messages(
&t,
if other_transport {
@@ -581,19 +615,20 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
}
assert_eq!(is_deleted(&t, "expired@localhost").await?, true);
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, is_chatmail);
assert_eq!(is_deleted(&t, "future@localhost").await?, is_chatmail);
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, !bcc_self);
assert_eq!(
is_deleted(&t, "no_expire_unencrypted@localhost").await?,
is_chatmail && !bcc_self
);
assert_eq!(is_deleted(&t, "future@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "expired_post@localhost").await?, true);
assert_eq!(is_deleted(&t, "expired_pre@localhost").await?, true);
assert_eq!(is_deleted(&t, "no_expire_post@localhost").await?, false);
assert_eq!(
is_deleted(&t, "no_expire_pre@localhost").await?,
is_chatmail
);
assert_eq!(is_deleted(&t, "no_expire_pre@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "future_post@localhost").await?, false);
assert_eq!(is_deleted(&t, "future_pre@localhost").await?, is_chatmail);
assert_eq!(is_deleted(&t, "done_pre@localhost").await?, is_chatmail);
assert_eq!(is_deleted(&t, "done_post@localhost").await?, is_chatmail);
assert_eq!(is_deleted(&t, "future_pre@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "done_pre@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "done_post@localhost").await?, !bcc_self);
reset_targets(&t).await;
}

View File

@@ -56,6 +56,15 @@ pub enum EnteredCertificateChecks {
AcceptInvalidCertificates2 = 3,
}
impl EnteredCertificateChecks {
pub(crate) fn accept_invalid_certificates(self) -> bool {
matches!(
self,
Self::AcceptInvalidCertificates | Self::AcceptInvalidCertificates2
)
}
}
/// Login parameters for a single IMAP server.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnteredImapLoginParam {

View File

@@ -140,8 +140,20 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1
Ok(())
}
pub(crate) async fn set_delivered(self, context: &Context) -> Result<()> {
update_msg_state(context, self, MessageState::OutDelivered).await?;
/// Returns whether the message state is updated to `OutDelivered`.
pub(crate) async fn set_delivered(self, context: &Context) -> Result<bool> {
if context
.sql
.execute(
// Only update `OutPending` i.e. if the message is (re-)sent to all chat members.
"UPDATE msgs SET state=?, error='' WHERE id=? AND state=?",
(MessageState::OutDelivered, self, MessageState::OutPending),
)
.await?
== 0
{
return Ok(false);
}
let chat_id: Option<ChatId> = context
.sql
.query_get_value("SELECT chat_id FROM msgs WHERE id=?", (self,))
@@ -153,7 +165,7 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1
if let Some(chat_id) = chat_id {
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}
Ok(())
Ok(true)
}
/// Bad evil escape hatch.
@@ -312,6 +324,7 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1
if duration != 0 {
ret += &format!("Duration: {duration} ms\n",);
}
ret += &format!("\nDatabase ID: {}", msg.id);
if !msg.rfc724_mid.is_empty() {
ret += &format!("\nMessage-ID: {}", msg.rfc724_mid);
@@ -1414,6 +1427,9 @@ pub enum MessageState {
/// The user has pressed the "send" button but the message is not
/// yet sent and is pending in some way. Maybe we're offline (no
/// checkmark).
///
/// This state means that the message is being (re-)sent to all chat members. It shalln't be
/// used e.g. for resending only to a new broadcast member.
OutPending = 20,
/// *Unrecoverable* error (*recoverable* errors result in pending
@@ -2026,13 +2042,6 @@ pub(crate) async fn update_msg_state(
Ok(())
}
// as we do not cut inside words, this results in about 32-42 characters.
// Do not use too long subjects - we add a tag after the subject which gets truncated by the clients otherwise.
// It should also be very clear, the subject is _not_ the whole message.
// The value is also used for CC:-summaries
// Context functions to work with messages
pub(crate) async fn set_msg_failed(
context: &Context,
msg: &mut Message,

View File

@@ -1168,12 +1168,6 @@ impl MimeFactory {
_ => None,
};
if context.get_config_bool(Config::TestHooks).await?
&& let Some(hook) = &*context.pre_encrypt_mime_hook.lock()
{
message = hook(context, message);
}
let encrypted = if let Some(shared_secret) = shared_secret {
let sign = true;
encrypt_helper

View File

@@ -1790,35 +1790,27 @@ async fn test_time_in_future() -> Result<()> {
Ok(())
}
/// Tests receiving a message with RFC 9788 header protection and legacy display element.
///
/// Legacy display elements should not be rendered:
/// <https://www.rfc-editor.org/rfc/rfc9788.html#name-do-not-render-legacy-displa>
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hp_legacy_display() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let mut msg = Message::new_text(
"Subject: Dinner plans\n\
\n\
Let's eat"
.to_string(),
);
msg.set_subject("Dinner plans".to_string());
let chat_id = alice.create_chat(bob).await.id;
alice.set_config_bool(Config::TestHooks, true).await?;
*alice.pre_encrypt_mime_hook.lock() = Some(|_, mut mime| {
for (h, v) in &mut mime.headers {
if h == "Content-Type"
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
{
*ct = ct.clone().attribute("hp-legacy-display", "1");
}
}
mime
});
let sent_msg = alice.send_msg(chat_id, &mut msg).await;
let msg_bob = bob.recv_msg(&sent_msg).await;
let msg_id = receive_imf(
bob,
include_bytes!("../../test-data/message/hp_legacy_display.eml"),
false,
)
.await?
.unwrap()
.msg_ids[0];
let msg_bob = Message::load_from_db(bob, msg_id).await?;
assert_eq!(msg_bob.subject, "Dinner plans");
// Legacy display element is removed from the text/plain body.
assert_eq!(msg_bob.text, "Let's eat");
Ok(())
}

View File

@@ -23,6 +23,7 @@ pub(crate) mod session;
pub(crate) mod tls;
use dns::lookup_host_with_cache;
pub(crate) use http::read_url_with_tls;
pub use http::{Response as HttpResponse, read_url, read_url_blob};
use tls::wrap_tls;

View File

@@ -231,7 +231,10 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
HashMap::from([
(
"imap.163.com",
vec![IpAddr::V4(Ipv4Addr::new(111, 124, 203, 45))],
vec![
IpAddr::V4(Ipv4Addr::new(111, 124, 203, 45)),
IpAddr::V4(Ipv4Addr::new(111, 124, 203, 50)),
],
),
(
"smtp.163.com",
@@ -422,12 +425,12 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"nine.testrun.org",
vec![
IpAddr::V4(Ipv4Addr::new(128, 140, 126, 197)),
IpAddr::V4(Ipv4Addr::new(116, 202, 233, 236)),
IpAddr::V4(Ipv4Addr::new(216, 144, 228, 100)),
IpAddr::V6(Ipv6Addr::new(0x2a01, 0x4f8, 0x241, 0x4ce8, 0, 0, 0, 2)),
IpAddr::V4(Ipv4Addr::new(77, 42, 49, 41)),
IpAddr::V6(Ipv6Addr::new(
0x2001, 0x41d0, 0x701, 0x1100, 0, 0, 0, 0x8ab1,
)),
IpAddr::V6(Ipv6Addr::new(0x2a01, 0x4f9, 0xfff1, 0x59, 0, 0, 0, 1)),
],
),
(
@@ -697,6 +700,10 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"chatmail.hackea.org",
vec![IpAddr::V4(Ipv4Addr::new(82, 165, 11, 85))],
),
(
"chat.adminforge.de",
vec![IpAddr::V4(Ipv4Addr::new(94, 130, 17, 142))],
),
(
"chika.aangat.lahat.computer",
vec![IpAddr::V4(Ipv4Addr::new(71, 19, 150, 113))],
@@ -738,6 +745,46 @@ static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new
"danneskjold.de",
vec![IpAddr::V4(Ipv4Addr::new(46, 62, 216, 132))],
),
(
"chat.in-the.eu",
vec![IpAddr::V4(Ipv4Addr::new(78, 46, 190, 129))],
),
(
"chat.nuvon.app",
vec![IpAddr::V4(Ipv4Addr::new(178, 238, 38, 165))],
),
(
"nibblehole.com",
vec![IpAddr::V4(Ipv4Addr::new(94, 247, 42, 209))],
),
(
"chat.zashm.org",
vec![IpAddr::V4(Ipv4Addr::new(91, 245, 76, 39))],
),
(
"chat.sus.fr",
vec![IpAddr::V4(Ipv4Addr::new(152, 67, 76, 190))],
),
(
"delta.thelab.uno",
vec![IpAddr::V4(Ipv4Addr::new(146, 59, 228, 39))],
),
(
"chat.vim.wtf",
vec![IpAddr::V4(Ipv4Addr::new(116, 203, 206, 170))],
),
(
"uninterest.ing",
vec![IpAddr::V4(Ipv4Addr::new(172, 245, 70, 237))],
),
(
"sweetfern.net",
vec![IpAddr::V4(Ipv4Addr::new(178, 156, 228, 133))],
),
(
"delta.disobey.net",
vec![IpAddr::V4(Ipv4Addr::new(37, 74, 102, 44))],
),
(
"darkrun.dev",
vec![IpAddr::V4(Ipv4Addr::new(72, 11, 149, 146))],

View File

@@ -13,7 +13,7 @@ use crate::context::Context;
use crate::log::warn;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_rustls;
use crate::net::tls::wrap_tls;
use crate::tools::time;
/// User-Agent for HTTP requests if a resource usage policy requires it.
@@ -35,7 +35,16 @@ pub struct Response {
/// Retrieves the text contents of URL using HTTP GET request.
pub async fn read_url(context: &Context, url: &str) -> Result<String> {
let response = read_url_blob(context, url).await?;
read_url_with_tls(context, url, true).await
}
/// Retrieves the text contents of URL using HTTP GET request.
pub(crate) async fn read_url_with_tls(
context: &Context,
url: &str,
strict_tls: bool,
) -> Result<String> {
let response = read_url_blob_with_tls(context, url, strict_tls).await?;
let text = String::from_utf8_lossy(&response.blob);
Ok(text.to_string())
}
@@ -43,6 +52,7 @@ pub async fn read_url(context: &Context, url: &str) -> Result<String> {
async fn get_http_sender<B>(
context: &Context,
parsed_url: hyper::Uri,
strict_tls: bool,
) -> Result<hyper::client::conn::http1::SendRequest<B>>
where
B: hyper::body::Body + 'static + Send,
@@ -76,37 +86,29 @@ where
let port = parsed_url.port_u16().unwrap_or(443);
let (use_sni, load_cache) = (true, true);
if let Some(proxy_config) = proxy_config_opt {
let tcp_stream: Box<dyn SessionStream> = if let Some(proxy_config) = proxy_config_opt {
let proxy_stream = proxy_config
.connect(context, host, port, load_cache)
.await?;
let tls_stream = wrap_rustls(
host,
port,
use_sni,
"",
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
Box::new(tls_stream)
Box::new(proxy_stream)
} else {
let tcp_stream = crate::net::connect_tcp(context, host, port, load_cache).await?;
let tls_stream = wrap_rustls(
host,
port,
use_sni,
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
Box::new(tls_stream)
}
Box::new(tcp_stream)
};
let tls_stream = wrap_tls(
strict_tls,
host,
port,
use_sni,
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
Box::new(tls_stream)
}
_ => bail!("Unknown URL scheme"),
};
@@ -260,7 +262,7 @@ pub(crate) async fn http_cache_cleanup(context: &Context) -> Result<()> {
/// Fetches URL and updates the cache.
///
/// URL is fetched regardless of whether there is an existing result in the cache.
async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
async fn fetch_url(context: &Context, original_url: &str, strict_tls: bool) -> Result<Response> {
let mut url = original_url.to_string();
// Follow up to 10 http-redirects
@@ -269,7 +271,7 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
.parse::<hyper::Uri>()
.with_context(|| format!("Failed to parse URL {url:?}"))?;
let mut sender = get_http_sender(context, parsed_url.clone()).await?;
let mut sender = get_http_sender(context, parsed_url.clone(), strict_tls).await?;
let authority = parsed_url
.authority()
.context("URL has no authority")?
@@ -339,8 +341,10 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
mimetype,
encoding,
};
info!(context, "Inserting {original_url:?} into cache.");
http_cache_put(context, &url, &response).await?;
if strict_tls {
info!(context, "Inserting {original_url:?} into cache.");
http_cache_put(context, &url, &response).await?;
}
return Ok(response);
}
@@ -349,6 +353,23 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
/// Retrieves the binary contents of URL using HTTP GET request.
pub async fn read_url_blob(context: &Context, url: &str) -> Result<Response> {
read_url_blob_with_tls(context, url, true).await
}
/// Retrieves the binary contents of URL using HTTP GET request.
pub(crate) async fn read_url_blob_with_tls(
context: &Context,
url: &str,
strict_tls: bool,
) -> Result<Response> {
if !strict_tls {
info!(
context,
"Fetching {url:?} without HTTP cache due to relaxed TLS."
);
return fetch_url(context, url, strict_tls).await;
}
if let Some((response, is_stale)) = http_cache_get(context, url).await? {
info!(context, "Returning {url:?} from cache.");
if is_stale {
@@ -357,7 +378,7 @@ pub async fn read_url_blob(context: &Context, url: &str) -> Result<Response> {
tokio::spawn(async move {
// Fetch URL in background to update the cache.
info!(context, "Fetching stale {url:?} in background.");
if let Err(err) = fetch_url(&context, &url).await {
if let Err(err) = fetch_url(&context, &url, true).await {
warn!(context, "Failed to revalidate {url:?}: {err:#}.");
}
});
@@ -366,7 +387,7 @@ pub async fn read_url_blob(context: &Context, url: &str) -> Result<Response> {
}
info!(context, "Not found {url:?} in cache, fetching.");
let response = fetch_url(context, url).await?;
let response = fetch_url(context, url, true).await?;
Ok(response)
}
@@ -384,7 +405,7 @@ pub(crate) async fn post_empty(context: &Context, url: &str) -> Result<(String,
bail!("POST requests to non-HTTPS URLs are not allowed");
}
let mut sender = get_http_sender(context, parsed_url.clone()).await?;
let mut sender = get_http_sender(context, parsed_url.clone(), true).await?;
let authority = parsed_url
.authority()
.context("URL has no authority")?
@@ -418,7 +439,7 @@ pub(crate) async fn post_string(context: &Context, url: &str, body: String) -> R
bail!("POST requests to non-HTTPS URLs are not allowed");
}
let mut sender = get_http_sender(context, parsed_url.clone()).await?;
let mut sender = get_http_sender(context, parsed_url.clone(), true).await?;
let authority = parsed_url
.authority()
.context("URL has no authority")?
@@ -449,7 +470,7 @@ pub(crate) async fn post_form<T: Serialize + ?Sized>(
}
let encoded_body = serde_urlencoded::to_string(form).context("Failed to encode data")?;
let mut sender = get_http_sender(context, parsed_url.clone()).await?;
let mut sender = get_http_sender(context, parsed_url.clone(), true).await?;
let authority = parsed_url
.authority()
.context("URL has no authority")?

View File

@@ -126,9 +126,12 @@ pub async fn wrap_rustls<'a>(
let root_cert_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config = rustls::ClientConfig::builder()
.with_root_certificates(root_cert_store)
.with_no_client_auth();
let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()?
.with_root_certificates(root_cert_store)
.with_no_client_auth();
config.alpn_protocols = if alpn.is_empty() {
vec![]
} else {

View File

@@ -51,7 +51,7 @@ impl rustls::client::danger::ServerCertVerifier for CustomCertificateVerifier {
let spki = parsed_certificate.subject_public_key_info();
let provider = rustls::crypto::ring::default_provider();
let provider = rustls::crypto::aws_lc_rs::default_provider();
if let ServerName::DnsName(dns_name) = server_name
&& dns_name.as_ref().starts_with("_")
@@ -97,7 +97,7 @@ impl rustls::client::danger::ServerCertVerifier for CustomCertificateVerifier {
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
let provider = rustls::crypto::ring::default_provider();
let provider = rustls::crypto::aws_lc_rs::default_provider();
let supported_schemes = &provider.signature_verification_algorithms;
rustls::crypto::verify_tls12_signature(message, cert, dss, supported_schemes)
}
@@ -108,13 +108,13 @@ impl rustls::client::danger::ServerCertVerifier for CustomCertificateVerifier {
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
let provider = rustls::crypto::ring::default_provider();
let provider = rustls::crypto::aws_lc_rs::default_provider();
let supported_schemes = &provider.signature_verification_algorithms;
rustls::crypto::verify_tls13_signature(message, cert, dss, supported_schemes)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
let provider = rustls::crypto::ring::default_provider();
let provider = rustls::crypto::aws_lc_rs::default_provider();
provider
.signature_verification_algorithms
.supported_schemes()

View File

@@ -3790,13 +3790,17 @@ async fn apply_out_broadcast_changes(
} else if from_id == ContactId::SELF
&& let Some(removed_id) = removed_id
{
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, removed_id)
.await?;
better_msg.get_or_insert(
stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await,
);
added_removed_id = Some(removed_id);
if chat::remove_from_chat_contacts_table_without_trace(context, chat.id, removed_id)
.await?
{
better_msg.get_or_insert(
stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await,
);
added_removed_id = Some(removed_id);
} else {
info!(context, "No-op broadcast member removal message (TRASH).");
better_msg = Some("".to_string());
}
}
}
@@ -3870,17 +3874,20 @@ async fn apply_in_broadcast_changes(
}
chat::delete_broadcast_secret(context, chat.id).await?;
if from_id == ContactId::SELF {
let removed =
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, ContactId::SELF)
.await?;
if !removed {
info!(context, "No-op broadcast SELF-removal message (TRASH).");
better_msg = Some("".to_string());
} else if from_id == ContactId::SELF {
better_msg.get_or_insert(stock_str::msg_you_left_broadcast(context));
} else {
better_msg.get_or_insert(
stock_str::msg_del_member_local(context, ContactId::SELF, from_id).await,
);
}
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, ContactId::SELF)
.await?;
send_event_chat_modified = true;
send_event_chat_modified |= removed;
} else if !chat.is_self_in_chat(context).await? {
chat::add_to_chat_contacts_table(
context,

View File

@@ -421,7 +421,7 @@ impl Context {
// If not supported by the provider,
// just skip the "quota" section.
if !matches!(e, crate::quota::Error::NotSupportedByProvider) {
ret += &format!("Quota: {}", &*escaper::encode_minimal(&e.to_string()));
ret += &escaper::encode_minimal(&e.to_string());
}
}
Ok(quota) => {

View File

@@ -2346,7 +2346,7 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
transaction.execute(
"UPDATE transports
SET entered_param=json_set(entered_param, '$.imap.folder', ?1),
configured_param=json_set(configured_param', '$.imap_folder', ?1)",
configured_param=json_set(configured_param, '$.imap_folder', ?1)",
(mvbox_folder,),
)?;
}

View File

@@ -18,7 +18,7 @@ use crate::config::Config;
use crate::constants::{Chattype, DC_VERSION_STR};
use crate::contact::{Contact, ContactId, Origin, import_vcard, mark_contact_id_as_verified};
use crate::context::Context;
use crate::key::load_self_public_keyring;
use crate::key::{DcKey, load_self_public_key};
use crate::log::LogExt;
use crate::message::{Message, Viewtype};
use crate::securejoin::QrInvite;
@@ -33,7 +33,14 @@ const MESSAGE_STATS_UPDATE_INTERVAL_SECONDS: i64 = 4 * 60; // 4 minutes (less th
#[derive(Serialize)]
struct Statistics {
core_version: String,
number_of_transports: usize,
key_create_timestamps: Vec<u32>,
number_of_keys: u32,
/// OpenPGP version of the key.
key_version: u8,
key_algorithm: String,
/// Size of the public key in bytes (encoded in binary, not base64).
pubkey_size: usize,
stats_id: String,
is_chatmail: bool,
contact_stats: Vec<ContactStat>,
@@ -345,11 +352,15 @@ async fn get_stats(context: &Context) -> Result<String> {
.get_config_u32(Config::StatsLastOldContactId)
.await?;
let key_create_timestamps: Vec<u32> = load_self_public_keyring(context)
let self_public_key = load_self_public_key(context).await?;
// `key_create_timestamps` is a `Vec` for historical reasons,
// support for using multiple keys is being phased out.
let key_create_timestamps: Vec<u32> = vec![self_public_key.created_at().as_secs()];
let number_of_keys: u32 = context
.sql
.query_get_value("SELECT COUNT(*) FROM keypairs", ())
.await?
.iter()
.map(|k| k.created_at().as_secs())
.collect();
.unwrap_or(0);
let sending_enabled_timestamps =
get_timestamps(context, "stats_sending_enabled_events").await?;
@@ -358,7 +369,12 @@ async fn get_stats(context: &Context) -> Result<String> {
let stats = Statistics {
core_version: DC_VERSION_STR.to_string(),
number_of_transports: context.count_transports().await?,
key_create_timestamps,
number_of_keys,
key_version: self_public_key.primary_key.version().into(),
key_algorithm: format!("{:?}", self_public_key.algorithm()),
pubkey_size: DcKey::to_bytes(&self_public_key).len(),
stats_id: stats_id(context).await?,
is_chatmail: context.is_chatmail().await?,
contact_stats: get_contact_stats(context, last_old_contact).await?,

View File

@@ -595,3 +595,33 @@ async fn test_stats_enable_disable_timestamps() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cryptography_stats() -> Result<()> {
let alice = &TestContext::new_alice().await;
let stats = get_stats(alice).await.unwrap();
let stats: serde_json::Value = serde_json::from_str(&stats)?;
let number_of_transports: u64 = stats.get("number_of_transports").unwrap().as_u64().unwrap();
assert_eq!(number_of_transports, 1);
let key_version = stats.get("key_version").unwrap().as_u64().unwrap();
// Alice's key is v4
assert_eq!(key_version, 4);
let key_algorithm = stats.get("key_algorithm").unwrap().as_str().unwrap();
assert_eq!(key_algorithm, "EdDSALegacy");
let pubkey_size = stats.get("pubkey_size").unwrap().as_u64().unwrap();
assert_eq!(pubkey_size, 583);
crate::transport::add_pseudo_transport(alice, "alice@ten.testrun.org").await?;
let stats = get_stats(alice).await.unwrap();
let stats: serde_json::Value = serde_json::from_str(&stats)?;
let number_of_transports: u64 = stats.get("number_of_transports").unwrap().as_u64().unwrap();
assert_eq!(number_of_transports, 2);
Ok(())
}

View File

@@ -38,7 +38,7 @@ use crate::events::{Event, EventEmitter, EventType, Events};
use crate::key::{self, DcKey, self_fingerprint};
use crate::log::warn;
use crate::login_param::EnteredLoginParam;
use crate::message::{Message, MessageState, MsgId, update_msg_state};
use crate::message::{Message, MessageState, MsgId};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::pgp::SeipdVersion;
use crate::receive_imf::{ReceivedMsg, receive_imf};
@@ -692,10 +692,11 @@ ORDER BY id"
if !msg_has_pending_smtp_job(self, msg_id)
.await
.expect("Failed to check for more jobs")
{
update_msg_state(&self.ctx, msg_id, MessageState::OutDelivered)
&& msg_id
.set_delivered(self)
.await
.expect("failed to update message state");
.expect("MsgId::set_delivered")
{
self.sql
.execute(
"UPDATE msgs SET timestamp_sent=? WHERE id=?",
@@ -772,16 +773,19 @@ ORDER BY id"
.execute("DELETE FROM smtp WHERE msg_id=?", (msg_id,))
.await
.expect("Delete smtp jobs");
update_msg_state(&self.ctx, msg_id, MessageState::OutDelivered)
if msg_id
.set_delivered(self)
.await
.expect("Update message state");
self.sql
.execute(
"UPDATE msgs SET timestamp_sent=? WHERE id=?",
(time(), msg_id),
)
.await
.expect("Update timestamp_sent");
.expect("MsgId::set_delivered")
{
self.sql
.execute(
"UPDATE msgs SET timestamp_sent=? WHERE id=?",
(time(), msg_id),
)
.await
.expect("Update timestamp_sent");
}
sent_msgs
}

View File

@@ -41,6 +41,7 @@ async fn test_parse_receive_headers_integration() {
let raw = include_bytes!("../../test-data/message/mail_with_cc.txt");
let expected = r"State: Fresh
Database ID: X
Message-ID: 2dfdbde7@example.org
Hop: From: localhost; By: hq5.merlinux.eu; Date: Sat, 14 Sep 2019 17:00:22 +0000
@@ -50,6 +51,7 @@ Hop: From: hq5.merlinux.eu; By: hq5.merlinux.eu; Date: Sat, 14 Sep 2019 17:00:25
let raw = include_bytes!("../../test-data/message/encrypted_with_received_headers.eml");
let expected = "State: Fresh, Encrypted
Database ID: X
Message-ID: Mr.adQpEwndXLH.LPDdlFVJ7wG@example.net
Hop: From: [127.0.0.1]; By: mail.example.org; Date: Mon, 27 Dec 2021 11:21:21 +0000
@@ -71,7 +73,10 @@ async fn check_parse_receive_headers_integration(raw: &[u8], expected: &str) {
// received time that depends on the test time which makes it impossible to
// compare with a static string
let capped_result = &msg_info[msg_info.find("State").unwrap()..];
assert_eq!(expected, capped_result);
assert_eq!(
expected.replace("\nDatabase ID: X", &format!("\nDatabase ID: {msg_id}")),
capped_result
);
}
#[test]

View File

@@ -252,7 +252,11 @@ impl fmt::Display for ConfiguredLoginParam {
write!(f, "{imap}")?;
first = false;
}
write!(f, "] smtp:[")?;
write!(f, "]")?;
if let Some(folder) = &self.imap_folder {
write!(f, " folder:{folder:?}")?;
}
write!(f, " smtp:[")?;
let mut first = true;
for smtp in &self.smtp {
if !first {

View File

@@ -34,7 +34,7 @@ async fn test_save_load_login_param() -> Result<()> {
},
user: "alice".to_string(),
}],
imap_folder: None,
imap_folder: Some("Folder".to_string()),
imap_user: "".to_string(),
imap_password: "foo".to_string(),
smtp: vec![ConfiguredServerLoginParam {
@@ -56,7 +56,7 @@ async fn test_save_load_login_param() -> Result<()> {
.clone()
.save_to_transports_table(&t, &EnteredLoginParam::default(), time())
.await?;
let expected_param = r#"{"addr":"alice@example.org","imap":[{"connection":{"host":"imap.example.com","port":123,"security":"Starttls"},"user":"alice"}],"imap_user":"","imap_password":"foo","smtp":[{"connection":{"host":"smtp.example.com","port":456,"security":"Tls"},"user":"alice@example.org"}],"smtp_user":"","smtp_password":"bar","provider_id":null,"certificate_checks":"Strict","oauth2":false}"#;
let expected_param = r#"{"addr":"alice@example.org","imap":[{"connection":{"host":"imap.example.com","port":123,"security":"Starttls"},"user":"alice"}],"imap_folder":"Folder","imap_user":"","imap_password":"foo","smtp":[{"connection":{"host":"smtp.example.com","port":456,"security":"Tls"},"user":"alice@example.org"}],"smtp_user":"","smtp_password":"bar","provider_id":null,"certificate_checks":"Strict","oauth2":false}"#;
assert_eq!(
t.sql
.query_get_value::<String>("SELECT configured_param FROM transports", ())
@@ -68,6 +68,14 @@ async fn test_save_load_login_param() -> Result<()> {
let (_transport_id, loaded) = ConfiguredLoginParam::load(&t).await?.unwrap();
assert_eq!(param, loaded);
let formatted = format!(" {loaded}");
assert!(formatted.contains(" ***@example.org"));
assert!(formatted.contains(" imap:[imap.example.com:123:starttls]"));
assert!(formatted.contains(" folder:\"Folder\""));
assert!(formatted.contains(" smtp:[smtp.example.com:456:tls]"));
assert!(formatted.contains(" provider:none"));
assert!(formatted.contains(" cert_strict"));
// Legacy ConfiguredImapCertificateChecks config is ignored
t.set_config(Config::ConfiguredImapCertificateChecks, Some("999"))
.await?;

View File

@@ -0,0 +1,63 @@
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="18b255ee53144064_9da6abff99cb33df_3854fea559a18923"
MIME-Version: 1.0
From: <alice@example.org>
To: "hidden-recipients": ;
Subject: [...]
Date: Fri, 22 May 2026 22:48:16 +0000
Message-ID: <3a5ae0d2-be4b-4463-8011-ab4a354ee690@localhost>
Chat-Version: 1.0
--18b255ee53144064_9da6abff99cb33df_3854fea559a18923
Content-Type: application/pgp-encrypted; charset="utf-8"
Content-Transfer-Encoding: 7bit
Version: 1
--18b255ee53144064_9da6abff99cb33df_3854fea559a18923
Content-Type: application/octet-stream; charset="utf-8"
Content-Transfer-Encoding: 7bit
-----BEGIN PGP MESSAGE-----
wUcGABIBB0BNBleU/G9GwCpizow72+5GANtiX+F7y/gI45x7MJLpFiBi+7OqwdFk
dmINorerXmnk1jR7TEppDYhjCBRWaCXHJsHARQYAAQf/Zj43ZUA3rtGS6Nq9vUcc
0L6knU0ukLc3KGTExQXu4ktiPT0RIjQ11wtSGlS75Bj7tu3syDf2/oQXi7eUh35T
IULpBQ5Pnpk6h26GX9WbRYR5zmBNnlyppqJcaYueQzPX1gvybJ4cwfOmnMdUqzFr
D9BR/YeyrBETsGKR+UfH167xgGY8d0ev8Tt4X01muiyrwLVqfoGCRUwjQFUY+ClY
9yuCrNDnu8fHY/WRTKXoDkA8Qn9/6TdJM7G41wOaJYbWn4R8WqjqoQNdUYwL2cBg
Pj1SNgaTaLMMH8YEtlRUN3JHiiqaBVIJIKBduOXnVEovf8FVXlmqX8DARLvqFCk2
LdLEjQIHAgdxw8asmmKOpatAEwFHuVwkWe/ZbRhu3D1V38BzZEuSLHzQ/nWbWWVP
GRmcZPmpusvRGX5W4zC6ybr5jePkW1dEqpgFFeoywz5REQxnl2QDPMHbWRFmC+q5
FSA/zacYGIKmU761xWc1raaosN4uzf8ujJA8H+TQT4AKu4K2nzSMFpUl05sNToGN
FdCNAvsnM9/PAraE3pju6YK+l4Woo/MLVEkrDEcLYEC/TrbNCJ6cnx/3uU+9gZyp
ntXzwOJIIQ5el2qcSQuzec931Lr4UuX4k0FUKeLS24EiYid4K59iPHgKJn6E7grb
34bxbQXkIMug0nTBKuAOTv7MFF4/rzn5deAUXpKty5zYLjqGApo4o5nnEvY9inXP
3489C77lCTLuXgkASrYhqTZcFT6ewaLNJxKRQgLB/V8BY4DrckA/If31z57EK2Ju
5kXpUfZKw/qjnHELLLSmYqx0Sd6w37d0x4LxHIjnpBEmsjWagM9fhiBJyaitNfCz
hBEUAQoO2DlqrZMwAhKxADH8WUbXOhgXcj68NM185A+/UzMbbdV0cmLKynH3tlLX
d16ZyriiRcllSuISXYLTsUwlQeA0zIyuYItzk7bGkw/2k4AmxFAFZjZPvaHOrt98
BapEkxYp8wyhXdloknJ687E5Oa4UTsMKzRTrXRiPhVNstYcYnde4fZs0cUX0JJqE
bZrR+T8biflfGWidc0D0cSd2rx6RryvykFBzvZJH4zqNaHap3w5F0miZWfrePQ8Y
ljPCb162x3SAHc5RzhHH7tq4YatWlnncSAuGkZA7XMqBgFbIRdY2z+Hv5AQLj563
OHD0Dc0pXUZYcHVdJnY4svuiekjEYLaGzjklULBsQo4KQXb8i3SufS8JUwypwAT1
BzMWYQrT6cv3IQHtF9Yys6Z3ngYs9UoCIyqPHYFZYV8Y64injpBFtiY6kd0zvm/3
5D5c0DcmI6kq0LNNNzZg5F0oMoDvyny1pI5/IGxS1H3OyBNJlNZkftMadyvDydY7
khCjWzB9mTStrmeYVqJof/NyfMWJMa0NtJrqW+730FYnld95cGzf3gnRsTYd/CgD
vXVcj5F2/fxsDUekmMMAYBB3DCBv0yCJJfqaTgQKcwngOL0+kuGVnjT8qfjf0FQR
yzImEPJFTQFe+mpRALBoMSDRpzja6ZkU/AIOTT/xy1ZkfpLU/TpNk7XS6InsIjdH
wBzfPvDUV7hHWwgILE+DTlebZVEuVBuoKTM72paMomYzskTkCGnIkTxioA1SyJxJ
eOsSlNC30Le6rcbvz+kl+H7cW2RGZuCUub/UtCMlcdjlzEVAjwXgQZgys2mAwLPx
n61I6rO+eEphXr5F/S6/0NwomR/02FY5AmqeViKC0G6/bDJOO374vU3bix28xqeB
yYYeO+ZymhjqA7tLTEjrkrCh0vo1WoKSAWbhVqM9YUzAeusqyndZ2uLbTgCEayER
MviI27DrXTDpExV9qLyELpo0z18kwoFScJ5ULXDN4lpwfsCeg3ThvBJD86V+4faT
4neKUwzasjYvuzKqSRV4j89I5Bs1g8ERaQr3VZ4BnyjKxQ91eLsMDD+LMx2QW2lB
HPUTWc4qmSAItogwZRxGUj+dASjcuGwqVhabUxWpxPsVGdOp6D3au1M3k7EuvLLa
NmnDDyNRSU8d7Mc/Tp9swfPKd2ItI4cREXTpyu53ayzSD5/7LaDtpHUASdaDLgDv
2evlUTBRk2a8+trbhE0oy8zzRdbgiNrwcegGM+y5BYCdV5Gooerk4g8pnULtKT1R
VgWMKvXvFduvQsfXW+PKChw=
=EBeW
-----END PGP MESSAGE-----
--18b255ee53144064_9da6abff99cb33df_3854fea559a18923--