mirror of
https://github.com/chatmail/core.git
synced 2026-04-06 23:52:11 +03:00
Compare commits
94 Commits
simon/impr
...
v2.42.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983f43c33c | ||
|
|
5028842fd5 | ||
|
|
e78b509d0a | ||
|
|
583979c6fc | ||
|
|
5bfd8dd517 | ||
|
|
32b0ca81f8 | ||
|
|
8dd7e5c5dd | ||
|
|
5bb0b86f6a | ||
|
|
ed2b0e8f03 | ||
|
|
8152ff518e | ||
|
|
cbcfb7087e | ||
|
|
396104af47 | ||
|
|
69f6727751 | ||
|
|
b72a677f4c | ||
|
|
00e78eecf6 | ||
|
|
8b0621b724 | ||
|
|
63bf4c4f33 | ||
|
|
d6bce56d18 | ||
|
|
c8dec0dcdd | ||
|
|
509644ea5f | ||
|
|
3e95239e71 | ||
|
|
74d4b823d2 | ||
|
|
1bcfb90b90 | ||
|
|
411ee511ed | ||
|
|
e5a30c341c | ||
|
|
3d409c37a1 | ||
|
|
b46c86c9b7 | ||
|
|
e5e268f503 | ||
|
|
633536bb13 | ||
|
|
94ee485155 | ||
|
|
ec0dc8bcad | ||
|
|
49296e3014 | ||
|
|
2b93e856e4 | ||
|
|
c5be7df1d7 | ||
|
|
6b74cb6539 | ||
|
|
de2ac8cca2 | ||
|
|
085fcd2751 | ||
|
|
83f30e4a54 | ||
|
|
e79b4baa09 | ||
|
|
1e0c0d8efa | ||
|
|
378fb09c80 | ||
|
|
ff2fbebff0 | ||
|
|
50a73666fd | ||
|
|
61a8eff2ad | ||
|
|
cbd379fdf0 | ||
|
|
fe826f762e | ||
|
|
2019debe99 | ||
|
|
6c4f4bfd19 | ||
|
|
44b0736216 | ||
|
|
3b29469102 | ||
|
|
6325a35b5b | ||
|
|
c08644490a | ||
|
|
955f79923a | ||
|
|
c9026bff2c | ||
|
|
4fc0d0f53d | ||
|
|
1bf24618fa | ||
|
|
3f98e45c29 | ||
|
|
26ddcfaaed | ||
|
|
f0a12d493c | ||
|
|
c848ea7eda | ||
|
|
7c55356271 | ||
|
|
f4ee01ecca | ||
|
|
448c0d2268 | ||
|
|
3325270896 | ||
|
|
b563064b26 | ||
|
|
8d32d3ae0c | ||
|
|
c5f19f67a9 | ||
|
|
baeb31b5fa | ||
|
|
5d3bc00fd5 | ||
|
|
424928b660 | ||
|
|
1b8c732611 | ||
|
|
2531dfea1d | ||
|
|
9003b248aa | ||
|
|
35875f9b32 | ||
|
|
008e6c4af3 | ||
|
|
a6baba1852 | ||
|
|
a6b2a54e46 | ||
|
|
99aa99eb5b | ||
|
|
566395f1fa | ||
|
|
4ccd3cb665 | ||
|
|
f5e1e2678b | ||
|
|
c3a5e3ac0d | ||
|
|
b2f31c8148 | ||
|
|
29c57ad065 | ||
|
|
82a0d6b0ab | ||
|
|
5ff323ce15 | ||
|
|
a67a5299bf | ||
|
|
659d21aa9d | ||
|
|
8f604e74ec | ||
|
|
e1ebf3e96d | ||
|
|
76171aea2e | ||
|
|
96b8d1720e | ||
|
|
47b49fd02e | ||
|
|
f50e3d6ffa |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -20,7 +20,7 @@ permissions: {}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_VERSION: 1.92.0
|
||||
RUST_VERSION: 1.93.0
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
MSRV: 1.88.0
|
||||
@@ -59,9 +59,9 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918
|
||||
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979
|
||||
with:
|
||||
arguments: --all-features --workspace
|
||||
arguments: --workspace --all-features --locked
|
||||
command: check
|
||||
command-arguments: "-Dwarnings"
|
||||
|
||||
|
||||
195
CHANGELOG.md
195
CHANGELOG.md
@@ -1,5 +1,195 @@
|
||||
# Changelog
|
||||
|
||||
## [2.42.0] - 2026-02-10
|
||||
|
||||
### Fixes
|
||||
|
||||
- Set `mvbox_move` to '0' explicitly for existing chatmail profiles.
|
||||
It's needed to prevent device message about deprecated `mvbox_move` option from appearing in chatmail profiles.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Do not scan not watched folders.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update rPGP from 0.18.0 to 0.19.0.
|
||||
- cargo: Bump quick-xml from 0.38.4 to 0.39.0.
|
||||
|
||||
### Tests
|
||||
|
||||
- Remove test_dont_show_emails.
|
||||
|
||||
### Other
|
||||
|
||||
- Fix typo in CHANGELOG for marknoticed_all_chats.
|
||||
|
||||
## [2.41.0] - 2026-02-06
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Do not require `ShowEmails` to be set to `All` for adding second relay.
|
||||
- Use different strings for audio and video calls.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Don't set download state to Failure if message is available on another Session's transport ([#7684](https://github.com/chatmail/core/pull/7684)).
|
||||
- Make use of call stock strings.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: Bump `time` from 0.3.37 to 0.3.47.
|
||||
|
||||
## [2.40.0] - 2026-02-04
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Receive_imf: Log reasoning for chat assignment.
|
||||
- Use more fitting encryption info message.
|
||||
- Send Intended Recipient Fingerprint subpackets.
|
||||
- Trash messages with intended recipient fingerprints, but w/o our one included.
|
||||
- Do not collect email addresses from messages after configuration.
|
||||
- Add device message about legacy `mvbox_move`.
|
||||
- Never create IMAP folders.
|
||||
- Make summary for pre-messages look like summary for fully downloaded messages ([#7775](https://github.com/chatmail/core/pull/7775)).
|
||||
- Don't call `BlobObject::create_and_deduplicate()` when forwarding message to the same account.
|
||||
- Allow clients to specify whether a call has video initially or not ([#7740](https://github.com/chatmail/core/pull/7740)).
|
||||
- Do not load more than one own key from the keychain.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Cross-account forwarding of a message which `has_html()` ([#7791](https://github.com/chatmail/core/pull/7791)).
|
||||
- Make self-contact a key-contact even if key isn't generated yet.
|
||||
- `apply_group_changes()`: Check whether From is key-contact.
|
||||
- Don't add SELF to unencrypted chat created from encrypted message ([#7661](https://github.com/chatmail/core/pull/7661)).
|
||||
- Don't upscale images and test that image resolution isn't changed unnecessarily ([#7769](https://github.com/chatmail/core/pull/7769)).
|
||||
- Restart i/o when there are new transports in a sync message ([#7640](https://github.com/chatmail/core/pull/7640)).
|
||||
- `add_or_lookup_key_contacts*()`: Advance fingerprint_iter on invalid address.
|
||||
- `receive_imf`: Look up key contact by intended recipient fingerprint ([#7661](https://github.com/chatmail/core/pull/7661)).
|
||||
- Remove `Config::DeleteToTrash` and `Config::ConfiguredTrashFolder`.
|
||||
|
||||
### API-Changes
|
||||
|
||||
- jsonrpc(python): Process events forever by default.
|
||||
|
||||
### CI
|
||||
|
||||
- Make scripts/deny.sh test the locked version of dependencies.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove unneeded dbg! statements ([#7776](https://github.com/chatmail/core/pull/7776)).
|
||||
- Remove unused Context.is_inbox().
|
||||
- Rename lookup_key_contacts_by_address_list() to lookup_key_contacts_fallback_to_chat().
|
||||
- Mark `ProviderOptions` as `non_exhaustive`.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update provider database.
|
||||
- cargo: Update `bytes` from 1.11.0 to 1.11.1.
|
||||
- cargo: Bump tokio from 1.48.0 to 1.49.0.
|
||||
- cargo: Bump tokio-util from 0.7.17 to 0.7.18.
|
||||
- cargo: Bump libc from 0.2.178 to 0.2.180.
|
||||
- cargo: Bump quote from 1.0.42 to 1.0.44.
|
||||
- cargo: Bump syn from 2.0.111 to 2.0.114.
|
||||
- cargo: Bump human-panic from 2.0.4 to 2.0.6.
|
||||
- cargo: Bump chrono from 0.4.42 to 0.4.43.
|
||||
- cargo: Bump data-encoding from 2.9.0 to 2.10.0.
|
||||
- cargo: Bump colorutils-rs from 0.7.5 to 0.7.6.
|
||||
- Update provider database.
|
||||
- cargo: Bump thiserror from 2.0.17 to 2.0.18.
|
||||
- deps: Bump EmbarkStudios/cargo-deny-action from 2.0.14 to 2.0.15.
|
||||
- Remove RUSTSEC-2026-0002 exception from deny.toml.
|
||||
- cargo: Bump tokio-stream from 0.1.17 to 0.1.18.
|
||||
- cargo: Bump toml from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0.
|
||||
- cargo: Bump serde_json from 1.0.148 to 1.0.149.
|
||||
- cargo: Bump uuid from 1.19.0 to 1.20.0.
|
||||
- cargo: Bump rustls-pki-types from 1.13.2 to 1.14.0.
|
||||
- cargo: Bump tracing-subscriber from 0.3.20 to 0.3.22.
|
||||
|
||||
### Tests
|
||||
|
||||
- 2nd device receives message via new primary transport.
|
||||
- Make `test_dont_move_sync_msgs` less flaky.
|
||||
- Encrypted incoming message goes to encrypted 1:1 chat even if references messages in ad-hoc group.
|
||||
- Message in blocked chat arrives as InSeen.
|
||||
- Set `mvbox_move` to 0 for test rust accounts.
|
||||
|
||||
## [2.39.0] - 2026-01-23
|
||||
|
||||
### CI
|
||||
|
||||
- Update Rust to 1.93.0.
|
||||
|
||||
### Documentation
|
||||
|
||||
- RELEASE.md: Push preparation commit to the main branch before tagging.
|
||||
- RELEASE.md: Add section about dealing with failed releases.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Forward message with file ([#7755](https://github.com/chatmail/core/pull/7755)).
|
||||
- Do not additionally reduce the resolution of images that fit into the resolution-limit and are larger than the file-size-limit ([#7760](https://github.com/chatmail/core/pull/7760)).
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Merge v2.38.0 into main branch.
|
||||
- Cleanup deprecated functions/defines ([#7763](https://github.com/chatmail/core/pull/7763)).
|
||||
|
||||
## [2.38.0] - 2026-01-22
|
||||
|
||||
### API-Changes
|
||||
|
||||
- [**breaking**] Jsonrpc: remove `contacts` from `FullChat`. To migrate load contacts on demand via `get_contacts_by_ids` using `FullChat.contactIds` ([#7282](https://github.com/chatmail/core/pull/7282)).
|
||||
- jsonrpc: Add run_until parameter for bots ([#7688](https://github.com/chatmail/core/pull/7688)).
|
||||
- rust, jsonrpc: Add `get_message_read_receipt_count` method ([#7732](https://github.com/chatmail/core/pull/7732)).
|
||||
- rust and jsonrpc: Marknoticed_all_chats method to mark all chats as noticed, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
|
||||
- Public re-export of Connectivity ([#7737](https://github.com/chatmail/core/pull/7737)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix chat types.
|
||||
- Set_config_from_qr() configures context for "DCACCOUNT:" and "DCLOGIN:" QRs ([#7450](https://github.com/chatmail/core/pull/7450)).
|
||||
- Fix formatting of `indoc!` link.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Pre-messages / next version of download on demand ([#7371](https://github.com/chatmail/core/pull/7371)).
|
||||
- Connectivity view: move quota up and combine with IMAP state. ([#7653](https://github.com/chatmail/core/pull/7653)).
|
||||
- Execute sync message before checking for primary transport update.
|
||||
- Disable partial search by contact address.
|
||||
- Don't put text into post-message ([#7714](https://github.com/chatmail/core/pull/7714)).
|
||||
- Don't scale up Origin of multiple and broadcast recipients when sending a message.
|
||||
- pgp: Use preferred hash algorithm for signing instead of hardcoded SHA256.
|
||||
- In teamprofiles, don't mark chat as read on outgoing message ([#7717](https://github.com/chatmail/core/pull/7717)).
|
||||
- Send and apply MDNs to self ([#7005](https://github.com/chatmail/core/pull/7005))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Do not show contact address in message info ([#7695](https://github.com/chatmail/core/pull/7695)).
|
||||
- Take transport_id into account when marking messages with \Seen flags.
|
||||
- Send bcc-self messages to all own relays ([#7656](https://github.com/chatmail/core/pull/7656)).
|
||||
- Only emit TransportsModified if transports are really modified.
|
||||
- Logging errors in deltachat-rpc-server during startup ([#7707](https://github.com/chatmail/core/pull/7707)).
|
||||
- Use only lowercase letters for stats id ([#7700](https://github.com/chatmail/core/pull/7700)).
|
||||
- Hide incoming broadcasts in `DC_GCL_FOR_FORWARDING` ([#7726](https://github.com/chatmail/core/pull/7726)).
|
||||
- Do not resolve ICE server hostnames during IMAP loop.
|
||||
- More reliable parsing of `dclogin:` links with ip address as host ([#7734](https://github.com/chatmail/core/pull/7734)).
|
||||
- Don't remember old channel members in the database ([#7716](https://github.com/chatmail/core/pull/7716)).
|
||||
- Make it possible to leave and immediately delete a chat ([#7744](https://github.com/chatmail/core/pull/7744)).
|
||||
- Emit MsgsChanged instead of MsgsNoticed on self-MDN if chat still has fresh messages.
|
||||
- Prevent possible infinite loop with invalid `smtp` row ([#7746](https://github.com/chatmail/core/pull/7746)).
|
||||
- Sync broadcast subscribers list ([#7578](https://github.com/chatmail/core/pull/7578))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Don't use `concat!` in sql statements ([#7720](https://github.com/chatmail/core/pull/7720)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Port test_dont_move_sync_msgs to JSON-RPC ([#7676](https://github.com/chatmail/core/pull/7676)).
|
||||
- rpc-client: Replace remaining print()s with `logging` ([#6082](https://github.com/chatmail/core/pull/6082)).
|
||||
|
||||
## [2.37.0] - 2026-01-08
|
||||
|
||||
### API-Changes
|
||||
@@ -7543,3 +7733,8 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[2.35.0]: https://github.com/chatmail/core/compare/v2.34.0..v2.35.0
|
||||
[2.36.0]: https://github.com/chatmail/core/compare/v2.35.0..v2.36.0
|
||||
[2.37.0]: https://github.com/chatmail/core/compare/v2.36.0..v2.37.0
|
||||
[2.38.0]: https://github.com/chatmail/core/compare/v2.37.0..v2.38.0
|
||||
[2.39.0]: https://github.com/chatmail/core/compare/v2.38.0..v2.39.0
|
||||
[2.40.0]: https://github.com/chatmail/core/compare/v2.39.0..v2.40.0
|
||||
[2.41.0]: https://github.com/chatmail/core/compare/v2.40.0..v2.41.0
|
||||
[2.42.0]: https://github.com/chatmail/core/compare/v2.41.0..v2.42.0
|
||||
|
||||
329
Cargo.lock
generated
329
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.37.0"
|
||||
version = "2.42.0"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.88"
|
||||
@@ -78,10 +78,10 @@ num-derive = "0.4"
|
||||
num-traits = { workspace = true }
|
||||
parking_lot = "0.12.4"
|
||||
percent-encoding = "2.3"
|
||||
pgp = { version = "0.18.0", default-features = false }
|
||||
pgp = { version = "0.19.0", default-features = false }
|
||||
pin-project = "1"
|
||||
qrcodegen = "1.7.0"
|
||||
quick-xml = { version = "0.38", features = ["escape-html"] }
|
||||
quick-xml = { version = "0.39", features = ["escape-html"] }
|
||||
rand-old = { package = "rand", version = "0.8" }
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
@@ -182,7 +182,7 @@ harness = false
|
||||
anyhow = "1"
|
||||
async-channel = "2.5.0"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4.42", default-features = false }
|
||||
chrono = { version = "0.4.43", default-features = false }
|
||||
deltachat-contact-tools = { path = "deltachat-contact-tools" }
|
||||
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
|
||||
deltachat = { path = ".", default-features = false }
|
||||
@@ -202,7 +202,7 @@ serde_json = "1"
|
||||
tempfile = "3.24.0"
|
||||
thiserror = "2"
|
||||
tokio = "1"
|
||||
tokio-util = "0.7.17"
|
||||
tokio-util = "0.7.18"
|
||||
tracing-subscriber = "0.3"
|
||||
yerpc = "0.6.4"
|
||||
|
||||
|
||||
17
RELEASE.md
17
RELEASE.md
@@ -1,4 +1,4 @@
|
||||
# Releasing a new version of DeltaChat core
|
||||
# Releasing a new version of chatmail core
|
||||
|
||||
For example, to release version 1.116.0 of the core, do the following steps.
|
||||
|
||||
@@ -14,8 +14,17 @@ For example, to release version 1.116.0 of the core, do the following steps.
|
||||
5. Commit the changes as `chore(release): prepare for 1.116.0`.
|
||||
Optionally, use a separate branch like `prep-1.116.0` for this commit and open a PR for review.
|
||||
|
||||
6. Tag the release: `git tag --annotate v1.116.0`.
|
||||
6. Push the commit to the `main` branch.
|
||||
|
||||
7. Push the release tag: `git push origin v1.116.0`.
|
||||
7. Once the commit is on the `main` branch and passed CI, tag the release: `git tag --annotate v1.116.0`.
|
||||
|
||||
8. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
|
||||
8. Push the release tag: `git push origin v1.116.0`.
|
||||
|
||||
9. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
|
||||
|
||||
## Dealing with failed releases
|
||||
|
||||
Once you make a GitHub release,
|
||||
CI will try to build and publish [PyPI](https://pypi.org/) and [npm](https://www.npmjs.com/) packages.
|
||||
If this fails for some reason, do not modify the failed tag, do not delete it and do not force-push to the `main` branch.
|
||||
Fix the build process and tag a new release instead.
|
||||
|
||||
2
STYLE.md
2
STYLE.md
@@ -21,7 +21,7 @@ text TEXT DEFAULT '' NOT NULL -- message text
|
||||
```
|
||||
|
||||
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
|
||||
or [`indoc!](https://docs.rs/indoc).
|
||||
or [`indoc!`](https://docs.rs/indoc).
|
||||
Do not escape newlines like this:
|
||||
```
|
||||
sql.execute(
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn create_context() -> Context {
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
|
||||
let public = secret.signed_public_key();
|
||||
let public = secret.to_public_key();
|
||||
let key_pair = KeyPair { public, secret };
|
||||
store_self_keypair(&context, &key_pair)
|
||||
.await
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.37.0"
|
||||
version = "2.42.0"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -894,7 +894,7 @@ int dc_preconfigure_keypair (dc_context_t* context, const cha
|
||||
* the pseudo-chat DC_CHAT_ID_ARCHIVED_LINK is added if there are _any_ archived
|
||||
* chats
|
||||
* - the flag DC_GCL_FOR_FORWARDING sorts "Saved messages" to the top of the chatlist
|
||||
* and hides the "Device chat" and contact requests.
|
||||
* and hides the "Device chat", contact requests and incoming broadcasts.
|
||||
* typically used on forwarding, may be combined with DC_GCL_NO_SPECIALS
|
||||
* to also hide the archive link.
|
||||
* - if the flag DC_GCL_NO_SPECIALS is set, archive link is not added
|
||||
@@ -1242,9 +1242,12 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c
|
||||
* This needs to be a one-to-one chat.
|
||||
* @param place_call_info any data that other devices receive
|
||||
* in #DC_EVENT_INCOMING_CALL.
|
||||
* @param has_video Whether the call has video initially.
|
||||
* This allows the recipient's client to adjust incoming call UX.
|
||||
* A call can be upgraded to include video later.
|
||||
* @return ID of the system message announcing the call.
|
||||
*/
|
||||
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info);
|
||||
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info, int has_video);
|
||||
|
||||
|
||||
/**
|
||||
@@ -1563,7 +1566,7 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
|
||||
* Mark all messages in a chat as _noticed_.
|
||||
* _Noticed_ messages are no longer _fresh_ and do not count as being unseen
|
||||
* but are still waiting for being marked as "seen" using dc_markseen_msgs()
|
||||
* (IMAP/MDNs is not done for noticed messages).
|
||||
* (read receipts aren't sent for noticed messages).
|
||||
*
|
||||
* Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
|
||||
* See also dc_markseen_msgs().
|
||||
@@ -2220,10 +2223,6 @@ uint32_t dc_lookup_contact_id_by_addr (dc_context_t* context, const char*
|
||||
uint32_t dc_create_contact (dc_context_t* context, const char* name, const char* addr);
|
||||
|
||||
|
||||
|
||||
// Deprecated 2025-05-20, setting this flag is a no-op.
|
||||
#define DC_GCL_DEPRECATED_VERIFIED_ONLY 0x01
|
||||
|
||||
#define DC_GCL_ADD_SELF 0x02
|
||||
#define DC_GCL_ADDRESS 0x04
|
||||
|
||||
@@ -2296,17 +2295,6 @@ dc_array_t* dc_import_vcard (dc_context_t* context, const char*
|
||||
dc_array_t* dc_get_contacts (dc_context_t* context, uint32_t flags, const char* query);
|
||||
|
||||
|
||||
/**
|
||||
* Get the number of blocked contacts.
|
||||
*
|
||||
* @deprecated Deprecated 2021-02-22, use dc_array_get_cnt() on dc_get_blocked_contacts() instead.
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @return The number of blocked contacts.
|
||||
*/
|
||||
int dc_get_blocked_cnt (dc_context_t* context);
|
||||
|
||||
|
||||
/**
|
||||
* Get blocked contacts.
|
||||
*
|
||||
@@ -2579,7 +2567,6 @@ void dc_stop_ongoing_process (dc_context_t* context);
|
||||
#define DC_QR_FPR_MISMATCH 220 // id=contact
|
||||
#define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint
|
||||
#define DC_QR_ACCOUNT 250 // text1=domain
|
||||
#define DC_QR_BACKUP 251 // deprecated
|
||||
#define DC_QR_BACKUP2 252
|
||||
#define DC_QR_BACKUP_TOO_NEW 255
|
||||
#define DC_QR_PROXY 271 // text1=address (e.g. "127.0.0.1:9050")
|
||||
@@ -4675,7 +4662,6 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
|
||||
#define DC_INFO_LOCATION_ONLY 9
|
||||
#define DC_INFO_EPHEMERAL_TIMER_CHANGED 10
|
||||
#define DC_INFO_PROTECTION_ENABLED 11
|
||||
#define DC_INFO_PROTECTION_DISABLED 12 // deprecated 2025-07
|
||||
#define DC_INFO_INVALID_UNENCRYPTED_MAIL 13
|
||||
#define DC_INFO_WEBXDC_INFO_MESSAGE 32
|
||||
#define DC_INFO_CHAT_E2EE 50
|
||||
@@ -7022,61 +7008,16 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used in summaries.
|
||||
#define DC_STR_FILE 12
|
||||
|
||||
/// "Group name changed from %1$s to %2$s."
|
||||
///
|
||||
/// Used in status messages for group name changes.
|
||||
/// - %1$s will be replaced by the old group name
|
||||
/// - %2$s will be replaced by the new group name
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_MSGGRPNAME 15
|
||||
|
||||
/// "Group image changed."
|
||||
///
|
||||
/// Used in status messages for group images changes.
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_MSGGRPIMGCHANGED 16
|
||||
|
||||
/// "Member %1$s added."
|
||||
///
|
||||
/// Used in status messages for added members.
|
||||
/// - %1$s will be replaced by the name of the added member
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_MSGADDMEMBER 17
|
||||
|
||||
/// "Member %1$s removed."
|
||||
///
|
||||
/// Used in status messages for removed members.
|
||||
/// - %1$s will be replaced by the name of the removed member
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_MSGDELMEMBER 18
|
||||
|
||||
/// "Group left."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_MSGGROUPLEFT 19
|
||||
|
||||
/// "GIF"
|
||||
///
|
||||
/// Used in summaries.
|
||||
#define DC_STR_GIF 23
|
||||
|
||||
/// @deprecated 2025-07, this string is no longer needed.
|
||||
#define DC_STR_ENCRYPTEDMSG 24
|
||||
|
||||
/// "End-to-end encryption available."
|
||||
///
|
||||
/// Used to build the string returned by dc_get_contact_encrinfo().
|
||||
/// @deprecated 2026-01-23
|
||||
#define DC_STR_E2E_AVAILABLE 25
|
||||
|
||||
/// @deprecated Deprecated 2021-02-07, this string is no longer needed.
|
||||
#define DC_STR_ENCR_TRANSP 27
|
||||
|
||||
/// "No encryption."
|
||||
///
|
||||
/// Used to build the string returned by dc_get_contact_encrinfo().
|
||||
@@ -7087,90 +7028,23 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used to build the string returned by dc_get_contact_encrinfo().
|
||||
#define DC_STR_FINGERPRINTS 30
|
||||
|
||||
/// "Message opened"
|
||||
///
|
||||
/// Used in subjects of outgoing read receipts.
|
||||
///
|
||||
/// @deprecated Deprecated 2024-07-26
|
||||
#define DC_STR_READRCPT 31
|
||||
|
||||
/// "The message '%1$s' you sent was displayed on the screen of the recipient."
|
||||
///
|
||||
/// Used as message text of outgoing read receipts.
|
||||
/// - %1$s will be replaced by the subject of the displayed message
|
||||
///
|
||||
/// @deprecated Deprecated 2024-06-23
|
||||
#define DC_STR_READRCPT_MAILBODY 32
|
||||
|
||||
/// @deprecated Deprecated, this string is no longer needed.
|
||||
#define DC_STR_MSGGRPIMGDELETED 33
|
||||
|
||||
/// "End-to-end encryption preferred."
|
||||
///
|
||||
/// Used to build the string returned by dc_get_contact_encrinfo().
|
||||
/// @deprecated 2025-06-05
|
||||
#define DC_STR_E2E_PREFERRED 34
|
||||
|
||||
/// "%1$s verified"
|
||||
///
|
||||
/// Used in status messages.
|
||||
/// - %1$s will be replaced by the name of the verified contact
|
||||
#define DC_STR_CONTACT_VERIFIED 35
|
||||
|
||||
/// "Cannot establish guaranteed end-to-end encryption with %1$s."
|
||||
///
|
||||
/// Used in status messages.
|
||||
/// - %1$s will be replaced by the name of the contact that cannot be verified
|
||||
/// @deprecated 2025-06-05
|
||||
#define DC_STR_CONTACT_NOT_VERIFIED 36
|
||||
|
||||
/// "Changed setup for %1$s."
|
||||
///
|
||||
/// Used in status messages.
|
||||
/// - %1$s will be replaced by the name of the contact with the changed setup
|
||||
/// @deprecated 2025-06-05
|
||||
#define DC_STR_CONTACT_SETUP_CHANGED 37
|
||||
|
||||
/// "Archived chats"
|
||||
///
|
||||
/// Used as the name for the corresponding chatlist entry.
|
||||
#define DC_STR_ARCHIVEDCHATS 40
|
||||
|
||||
/// "Autocrypt Setup Message"
|
||||
///
|
||||
/// @deprecated 2025-04
|
||||
#define DC_STR_AC_SETUP_MSG_SUBJECT 42
|
||||
|
||||
/// "This is the Autocrypt Setup Message, open it in a compatible client to use your setup"
|
||||
///
|
||||
/// @deprecated 2025-04
|
||||
#define DC_STR_AC_SETUP_MSG_BODY 43
|
||||
|
||||
/// "Cannot login as %1$s."
|
||||
///
|
||||
/// Used in error strings.
|
||||
/// - %1$s will be replaced by the failing login name
|
||||
#define DC_STR_CANNOT_LOGIN 60
|
||||
|
||||
/// "%1$s by %2$s"
|
||||
///
|
||||
/// Used to concretize actions,
|
||||
/// - %1$s will be replaced by an action
|
||||
/// as #DC_STR_MSGADDMEMBER or #DC_STR_MSGGRPIMGCHANGED (full-stop removed, if any)
|
||||
/// - %2$s will be replaced by the name of the user taking that action
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_MSGACTIONBYUSER 62
|
||||
|
||||
/// "%1$s by me"
|
||||
///
|
||||
/// Used to concretize actions.
|
||||
/// - %1$s will be replaced by an action
|
||||
/// as #DC_STR_MSGADDMEMBER or #DC_STR_MSGGRPIMGCHANGED (full-stop removed, if any)
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_MSGACTIONBYME 63
|
||||
|
||||
/// "Location streaming enabled."
|
||||
///
|
||||
/// Used in status messages.
|
||||
@@ -7211,13 +7085,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used as message text for the message added to the device chat after successful login.
|
||||
#define DC_STR_WELCOME_MESSAGE 71
|
||||
|
||||
/// "Unknown sender for this chat. See 'info' for more details."
|
||||
///
|
||||
/// Use as message text if assigning the message to a chat is not totally correct.
|
||||
///
|
||||
/// @deprecated 2025-08-18
|
||||
#define DC_STR_UNKNOWN_SENDER_FOR_CHAT 72
|
||||
|
||||
/// "Message from %1$s"
|
||||
///
|
||||
/// Used in subjects of outgoing messages in one-to-one chats.
|
||||
@@ -7231,53 +7098,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// - %1$s will be replaced by the name of the contact the message cannot be sent to
|
||||
#define DC_STR_FAILED_SENDING_TO 74
|
||||
|
||||
/// "Message deletion timer is disabled."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_EPHEMERAL_DISABLED 75
|
||||
|
||||
/// "Message deletion timer is set to %1$s s."
|
||||
///
|
||||
/// Used in status messages when the other constants
|
||||
/// (#DC_STR_EPHEMERAL_MINUTE, #DC_STR_EPHEMERAL_HOUR and so on) do not match the timer.
|
||||
/// - %1$s will be replaced by the number of seconds the timer is set to
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_EPHEMERAL_SECONDS 76
|
||||
|
||||
/// "Message deletion timer is set to 1 minute."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_EPHEMERAL_MINUTE 77
|
||||
|
||||
/// "Message deletion timer is set to 1 hour."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_EPHEMERAL_HOUR 78
|
||||
|
||||
/// "Message deletion timer is set to 1 day."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_EPHEMERAL_DAY 79
|
||||
|
||||
/// "Message deletion timer is set to 1 week."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// @deprecated 2022-09-10
|
||||
#define DC_STR_EPHEMERAL_WEEK 80
|
||||
|
||||
/// @deprecated Deprecated 2021-01-30, DC_STR_EPHEMERAL_WEEKS is used instead.
|
||||
#define DC_STR_EPHEMERAL_FOUR_WEEKS 81
|
||||
|
||||
/// "Error: %1$s"
|
||||
///
|
||||
/// Used in error strings.
|
||||
@@ -7311,42 +7131,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used as device message text.
|
||||
#define DC_STR_SELF_DELETED_MSG_BODY 91
|
||||
|
||||
/// "Message deletion timer is set to %1$s minutes."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// `%1$s` will be replaced by the number of minutes (always >1) the timer is set to.
|
||||
///
|
||||
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_MINUTES and DC_STR_MSG_EPHEMERAL_TIMER_MINUTES_BY.
|
||||
#define DC_STR_EPHEMERAL_MINUTES 93
|
||||
|
||||
/// "Message deletion timer is set to %1$s hours."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// `%1$s` will be replaced by the number of hours (always >1) the timer is set to.
|
||||
///
|
||||
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_HOURS and DC_STR_MSG_EPHEMERAL_TIMER_HOURS_BY.
|
||||
#define DC_STR_EPHEMERAL_HOURS 94
|
||||
|
||||
/// "Message deletion timer is set to %1$s days."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// `%1$s` will be replaced by the number of days (always >1) the timer is set to.
|
||||
///
|
||||
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_DAYS and DC_STR_MSG_EPHEMERAL_TIMER_DAYS_BY.
|
||||
#define DC_STR_EPHEMERAL_DAYS 95
|
||||
|
||||
/// "Message deletion timer is set to %1$s weeks."
|
||||
///
|
||||
/// Used in status messages.
|
||||
///
|
||||
/// `%1$s` will be replaced by the number of weeks (always >1) the timer is set to.
|
||||
///
|
||||
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_WEEKS and DC_STR_MSG_EPHEMERAL_TIMER_WEEKS_BY.
|
||||
#define DC_STR_EPHEMERAL_WEEKS 96
|
||||
|
||||
/// "Forwarded"
|
||||
///
|
||||
/// Used in message summary text for notifications and chatlist.
|
||||
@@ -7359,9 +7143,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// `%1$s` will be replaced by the percentage used
|
||||
#define DC_STR_QUOTA_EXCEEDING_MSG_BODY 98
|
||||
|
||||
/// @deprecated Deprecated 2025-11-12, this string is no longer needed.
|
||||
#define DC_STR_PARTIAL_DOWNLOAD_MSG_BODY 99
|
||||
|
||||
/// "Multi Device Synchronization"
|
||||
///
|
||||
/// Used in subjects of outgoing sync messages.
|
||||
@@ -7387,10 +7168,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used as a headline in the connectivity view.
|
||||
#define DC_STR_OUTGOING_MESSAGES 104
|
||||
|
||||
|
||||
/// @deprecated Deprecated 2022-04-16, this string is no longer needed.
|
||||
#define DC_STR_ONE_MOMENT 106
|
||||
|
||||
/// "Connected"
|
||||
///
|
||||
/// Used as status in the connectivity view.
|
||||
@@ -7481,12 +7258,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used as status in the connectivity view.
|
||||
#define DC_STR_NOT_CONNECTED 121
|
||||
|
||||
/// "%1$s changed their address from %2$s to %3$s"
|
||||
///
|
||||
/// Used as an info message to chats with contacts that changed their address.
|
||||
/// @deprecated 2025-06-05
|
||||
#define DC_STR_AEAP_ADDR_CHANGED 122
|
||||
|
||||
/// "You changed group name from \"%1$s\" to \"%2$s\"."
|
||||
///
|
||||
/// `%1$s` will be replaced by the old group name.
|
||||
@@ -7601,17 +7372,6 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used in status messages.
|
||||
#define DC_STR_EPHEMERAL_TIMER_SECONDS_BY_OTHER 141
|
||||
|
||||
/// "You set message deletion timer to 1 minute."
|
||||
///
|
||||
/// @deprecated 2025-11-14, this string is no longer needed
|
||||
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU 142
|
||||
|
||||
/// "Message deletion timer is set to 1 minute by %1$s."
|
||||
///
|
||||
/// `%1$s` will be replaced by name of the contact.
|
||||
/// @deprecated 2025-11-14, this string is no longer needed
|
||||
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER 143
|
||||
|
||||
/// "You set message deletion timer to 1 hour."
|
||||
///
|
||||
/// Used in status messages.
|
||||
@@ -7781,26 +7541,9 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used as info message.
|
||||
#define DC_STR_SECUREJOIN_WAIT 190
|
||||
|
||||
/// "Could not yet establish guaranteed end-to-end encryption, but you may already send a message."
|
||||
///
|
||||
/// @deprecated 2025-03
|
||||
#define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191
|
||||
|
||||
/// "The contact must be online to proceed. This process will continue automatically in background."
|
||||
///
|
||||
/// Used as info message.
|
||||
/// @deprecated 2025-06-05
|
||||
#define DC_STR_SECUREJOIN_TAKES_LONGER 192
|
||||
|
||||
/// "❤️ Seems you're enjoying Delta Chat!"… (donation request device message)
|
||||
#define DC_STR_DONATION_REQUEST 193
|
||||
|
||||
/// "Outgoing call"
|
||||
#define DC_STR_OUTGOING_CALL 194
|
||||
|
||||
/// "Incoming call"
|
||||
#define DC_STR_INCOMING_CALL 195
|
||||
|
||||
/// "Declined call"
|
||||
#define DC_STR_DECLINED_CALL 196
|
||||
|
||||
@@ -7852,6 +7595,18 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// Used as the first info messages in newly created classic email threads.
|
||||
#define DC_STR_CHAT_UNENCRYPTED_EXPLANATON 230
|
||||
|
||||
/// "Outgoing audio call"
|
||||
#define DC_STR_OUTGOING_AUDIO_CALL 232
|
||||
|
||||
/// "Outgoing video call"
|
||||
#define DC_STR_OUTGOING_VIDEO_CALL 233
|
||||
|
||||
/// "Incoming audio call"
|
||||
#define DC_STR_INCOMING_AUDIO_CALL 234
|
||||
|
||||
/// "Incoming video call"
|
||||
#define DC_STR_INCOMING_VIDEO_CALL 235
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
|
||||
@@ -1181,6 +1181,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
|
||||
context: *mut dc_context_t,
|
||||
chat_id: u32,
|
||||
place_call_info: *const libc::c_char,
|
||||
has_video: bool,
|
||||
) -> u32 {
|
||||
if context.is_null() || chat_id == 0 {
|
||||
eprintln!("ignoring careless call to dc_place_outgoing_call()");
|
||||
@@ -1190,7 +1191,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
|
||||
let chat_id = ChatId::new(chat_id);
|
||||
let place_call_info = to_string_lossy(place_call_info);
|
||||
|
||||
block_on(ctx.place_outgoing_call(chat_id, place_call_info))
|
||||
block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video))
|
||||
.context("Failed to place call")
|
||||
.log_err(ctx)
|
||||
.map(|msg_id| msg_id.to_u32())
|
||||
@@ -2260,22 +2261,6 @@ pub unsafe extern "C" fn dc_get_contacts(
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_get_blocked_cnt(context: *mut dc_context_t) -> libc::c_int {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_get_blocked_cnt()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
|
||||
block_on(async move {
|
||||
Contact::get_all_blocked(ctx)
|
||||
.await
|
||||
.unwrap_or_log_default(ctx, "failed to get blocked count")
|
||||
.len() as libc::c_int
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_get_blocked_contacts(
|
||||
context: *mut dc_context_t,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.37.0"
|
||||
version = "2.42.0"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -11,8 +11,8 @@ use deltachat::blob::BlobObject;
|
||||
use deltachat::calls::ice_servers;
|
||||
use deltachat::chat::{
|
||||
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
|
||||
get_chat_msgs_ex, marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem,
|
||||
MessageListOptions,
|
||||
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
|
||||
ChatId, ChatItem, MessageListOptions,
|
||||
};
|
||||
use deltachat::chatlist::Chatlist;
|
||||
use deltachat::config::{get_all_ui_config_keys, Config};
|
||||
@@ -23,8 +23,8 @@ use deltachat::ephemeral::Timer;
|
||||
use deltachat::imex;
|
||||
use deltachat::location;
|
||||
use deltachat::message::{
|
||||
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipts, markseen_msgs, Message,
|
||||
MessageState, MsgId, Viewtype,
|
||||
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipt_count, get_msg_read_receipts,
|
||||
markseen_msgs, Message, MessageState, MsgId, Viewtype,
|
||||
};
|
||||
use deltachat::peer_channels::{
|
||||
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
|
||||
@@ -1164,10 +1164,24 @@ impl CommandApi {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Mark all messages in all chats as _noticed_.
|
||||
/// Skips messages from blocked contacts, but does not skip messages in muted chats.
|
||||
///
|
||||
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
|
||||
/// but are still waiting for being marked as "seen" using markseen_msgs()
|
||||
/// (read receipts aren't sent for noticed messages).
|
||||
///
|
||||
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
|
||||
/// See also markseen_msgs().
|
||||
pub async fn marknoticed_all_chats(&self, account_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
marknoticed_all_chats(&ctx).await
|
||||
}
|
||||
|
||||
/// Mark all messages in a chat as _noticed_.
|
||||
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
|
||||
/// but are still waiting for being marked as "seen" using markseen_msgs()
|
||||
/// (IMAP/MDNs is not done for noticed messages).
|
||||
/// (read receipts aren't sent for noticed messages).
|
||||
///
|
||||
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
|
||||
/// See also markseen_msgs().
|
||||
@@ -1434,6 +1448,18 @@ impl CommandApi {
|
||||
MessageInfo::from_msg_id(&ctx, MsgId::new(message_id)).await
|
||||
}
|
||||
|
||||
/// Returns count of read receipts on message.
|
||||
///
|
||||
/// This view count is meant as a feedback measure for the channel owner only.
|
||||
async fn get_message_read_receipt_count(
|
||||
&self,
|
||||
account_id: u32,
|
||||
message_id: u32,
|
||||
) -> Result<usize> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
get_msg_read_receipt_count(&ctx, MsgId::new(message_id)).await
|
||||
}
|
||||
|
||||
/// Returns contacts that sent read receipts and the time of reading.
|
||||
async fn get_message_read_receipts(
|
||||
&self,
|
||||
@@ -2141,10 +2167,11 @@ impl CommandApi {
|
||||
account_id: u32,
|
||||
chat_id: u32,
|
||||
place_call_info: String,
|
||||
has_video: bool,
|
||||
) -> Result<u32> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let msg_id = ctx
|
||||
.place_outgoing_call(ChatId::new(chat_id), place_call_info)
|
||||
.place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video)
|
||||
.await?;
|
||||
Ok(msg_id.to_u32())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use deltachat::calls::{call_state, sdp_has_video, CallState};
|
||||
use deltachat::calls::{call_state, CallState};
|
||||
use deltachat::context::Context;
|
||||
use deltachat::message::MsgId;
|
||||
use serde::Serialize;
|
||||
@@ -15,7 +15,7 @@ pub struct JsonrpcCallInfo {
|
||||
/// even if incoming call event was missed.
|
||||
pub sdp_offer: String,
|
||||
|
||||
/// True if SDP offer has a video.
|
||||
/// True if the call is started as a video call.
|
||||
pub has_video: bool,
|
||||
|
||||
/// Call state.
|
||||
@@ -30,7 +30,7 @@ impl JsonrpcCallInfo {
|
||||
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 has_video = call_info.has_video_initially();
|
||||
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;
|
||||
|
||||
Ok(JsonrpcCallInfo {
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.37.0"
|
||||
"version": "2.42.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.37.0"
|
||||
version = "2.42.0"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "2.37.0"
|
||||
version = "2.42.0"
|
||||
license = "MPL-2.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
|
||||
@@ -44,8 +44,13 @@ class AttrDict(dict):
|
||||
super().__setattr__(attr, val)
|
||||
|
||||
|
||||
def _forever(_event: AttrDict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def run_client_cli(
|
||||
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
|
||||
until: Callable[[AttrDict], bool] = _forever,
|
||||
argv: Optional[list] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
@@ -55,10 +60,11 @@ def run_client_cli(
|
||||
"""
|
||||
from .client import Client
|
||||
|
||||
_run_cli(Client, hooks, argv, **kwargs)
|
||||
_run_cli(Client, until, hooks, argv, **kwargs)
|
||||
|
||||
|
||||
def run_bot_cli(
|
||||
until: Callable[[AttrDict], bool] = _forever,
|
||||
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
|
||||
argv: Optional[list] = None,
|
||||
**kwargs,
|
||||
@@ -69,11 +75,12 @@ def run_bot_cli(
|
||||
"""
|
||||
from .client import Bot
|
||||
|
||||
_run_cli(Bot, hooks, argv, **kwargs)
|
||||
_run_cli(Bot, until, hooks, argv, **kwargs)
|
||||
|
||||
|
||||
def _run_cli(
|
||||
client_type: Type["Client"],
|
||||
until: Callable[[AttrDict], bool] = _forever,
|
||||
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
|
||||
argv: Optional[list] = None,
|
||||
**kwargs,
|
||||
@@ -111,7 +118,7 @@ def _run_cli(
|
||||
kwargs={"email": args.email, "password": args.password},
|
||||
)
|
||||
configure_thread.start()
|
||||
client.run_forever()
|
||||
client.run_until(until)
|
||||
|
||||
|
||||
def extract_addr(text: str) -> str:
|
||||
|
||||
@@ -303,7 +303,7 @@ class Chat:
|
||||
f.flush()
|
||||
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name})
|
||||
|
||||
def place_outgoing_call(self, place_call_info: str) -> Message:
|
||||
def place_outgoing_call(self, place_call_info: str, has_video_initially: bool) -> Message:
|
||||
"""Starts an outgoing call."""
|
||||
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info)
|
||||
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially)
|
||||
return Message(self.account, msg_id)
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import (
|
||||
|
||||
from ._utils import (
|
||||
AttrDict,
|
||||
_forever,
|
||||
parse_system_add_remove,
|
||||
parse_system_image_changed,
|
||||
parse_system_title_changed,
|
||||
@@ -91,19 +92,28 @@ class Client:
|
||||
|
||||
def run_forever(self) -> None:
|
||||
"""Process events forever."""
|
||||
self.run_until(lambda _: False)
|
||||
self.run_until(_forever)
|
||||
|
||||
def run_until(self, func: Callable[[AttrDict], bool]) -> AttrDict:
|
||||
"""Process events until the given callable evaluates to True.
|
||||
|
||||
The callable should accept an AttrDict object representing the
|
||||
last processed event. The event is returned when the callable
|
||||
evaluates to True.
|
||||
"""
|
||||
"""Start the event processing loop."""
|
||||
self.logger.debug("Listening to incoming events...")
|
||||
if self.is_configured():
|
||||
self.account.start_io()
|
||||
self._process_messages() # Process old messages.
|
||||
return self._process_events(until_func=func) # Loop over incoming events
|
||||
|
||||
def _process_events(
|
||||
self,
|
||||
until_func: Callable[[AttrDict], bool] = _forever,
|
||||
until_event: EventType = False,
|
||||
) -> AttrDict:
|
||||
"""Process events until the given callable evaluates to True,
|
||||
or until a certain event happens.
|
||||
|
||||
The until_func callable should accept an AttrDict object representing
|
||||
the last processed event. The event is returned when the callable
|
||||
evaluates to True.
|
||||
"""
|
||||
while True:
|
||||
event = self.account.wait_for_event()
|
||||
event["kind"] = EventType(event.kind)
|
||||
@@ -112,10 +122,13 @@ class Client:
|
||||
if event.kind == EventType.INCOMING_MSG:
|
||||
self._process_messages()
|
||||
|
||||
stop = func(event)
|
||||
stop = until_func(event)
|
||||
if stop:
|
||||
return event
|
||||
|
||||
if event.kind == until_event:
|
||||
return event
|
||||
|
||||
def _on_event(self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent) -> None:
|
||||
for hook, evfilter in self._hooks.get(filter_type, []):
|
||||
if evfilter.filter(event):
|
||||
|
||||
@@ -44,6 +44,14 @@ class Message:
|
||||
read_receipts = self._rpc.get_message_read_receipts(self.account.id, self.id)
|
||||
return [AttrDict(read_receipt) for read_receipt in read_receipts]
|
||||
|
||||
def get_read_receipt_count(self) -> int:
|
||||
"""
|
||||
Returns count of read receipts on message.
|
||||
|
||||
This view count is meant as a feedback measure for the channel owner only.
|
||||
"""
|
||||
return self._rpc.get_message_read_receipt_count(self.account.id, self.id)
|
||||
|
||||
def get_reactions(self) -> Optional[AttrDict]:
|
||||
"""Get message reactions."""
|
||||
reactions = self._rpc.get_message_reactions(self.account.id, self.id)
|
||||
|
||||
@@ -22,7 +22,7 @@ from .rpc import Rpc
|
||||
E2EE_INFO_MSGS = 1
|
||||
"""
|
||||
The number of info messages added to new e2ee chats.
|
||||
Currently this is "End-to-end encryption available".
|
||||
Currently this is "Messages are end-to-end encrypted."
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -10,15 +10,15 @@ 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)
|
||||
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, has_video_initially=True)
|
||||
assert outgoing_call_message.get_call_info().state.kind == "Alerting"
|
||||
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
assert incoming_call_event.place_call_info == place_call_info
|
||||
assert not incoming_call_event.has_video # Cannot be parsed as SDP, so false by default
|
||||
assert incoming_call_event.has_video
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
assert incoming_call_message.get_call_info().state.kind == "Alerting"
|
||||
assert not incoming_call_message.get_call_info().has_video
|
||||
assert incoming_call_message.get_call_info().has_video
|
||||
|
||||
incoming_call_message.accept_incoming_call(accept_call_info)
|
||||
assert incoming_call_message.get_call_info().sdp_offer == place_call_info
|
||||
@@ -41,46 +41,38 @@ def test_video_call(acfactory) -> None:
|
||||
#
|
||||
# `s=` cannot be empty according to RFC 3264,
|
||||
# so it is more clear as `s=-`.
|
||||
place_call_info = """v=0\r
|
||||
o=alice 2890844526 2890844526 IN IP6 2001:db8::3\r
|
||||
s=-\r
|
||||
c=IN IP6 2001:db8::3\r
|
||||
t=0 0\r
|
||||
a=group:BUNDLE foo bar\r
|
||||
\r
|
||||
m=audio 10000 RTP/AVP 0 8 97\r
|
||||
b=AS:200\r
|
||||
a=mid:foo\r
|
||||
a=rtcp-mux\r
|
||||
a=rtpmap:0 PCMU/8000\r
|
||||
a=rtpmap:8 PCMA/8000\r
|
||||
a=rtpmap:97 iLBC/8000\r
|
||||
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
|
||||
\r
|
||||
m=video 10002 RTP/AVP 31 32\r
|
||||
b=AS:1000\r
|
||||
a=mid:bar\r
|
||||
a=rtcp-mux\r
|
||||
a=rtpmap:31 H261/90000\r
|
||||
a=rtpmap:32 MPV/90000\r
|
||||
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)
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
|
||||
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
assert incoming_call_event.place_call_info == place_call_info
|
||||
assert incoming_call_event.place_call_info == "offer"
|
||||
assert incoming_call_event.has_video
|
||||
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
assert incoming_call_message.get_call_info().has_video
|
||||
|
||||
|
||||
def test_audio_call(acfactory) -> None:
|
||||
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("offer", has_video_initially=False)
|
||||
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
assert incoming_call_event.place_call_info == "offer"
|
||||
assert not incoming_call_event.has_video
|
||||
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
assert not incoming_call_message.get_call_info().has_video
|
||||
|
||||
|
||||
def test_ice_servers(acfactory) -> None:
|
||||
alice = acfactory.get_online_account()
|
||||
|
||||
@@ -92,7 +84,7 @@ 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.place_outgoing_call("offer", has_video_initially=True)
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
# Notification for "Hello!" message should arrive
|
||||
@@ -119,7 +111,7 @@ def test_who_can_call_me_nobody(acfactory) -> None:
|
||||
bob.create_chat(alice)
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
alice_chat_bob.place_outgoing_call("offer")
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
# Notification for "Hello!" message should arrive
|
||||
@@ -144,7 +136,7 @@ def test_who_can_call_me_everybody(acfactory) -> None:
|
||||
bob.set_config("who_can_call_me", "0")
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
alice_chat_bob.place_outgoing_call("offer")
|
||||
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
|
||||
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
|
||||
|
||||
incoming_call_message = Message(bob, incoming_call_event.msg_id)
|
||||
|
||||
@@ -8,8 +8,10 @@ from imap_tools import AND, U
|
||||
from deltachat_rpc_client import Contact, EventType, Message
|
||||
|
||||
|
||||
def test_move_works(acfactory):
|
||||
def test_move_works(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.bring_online()
|
||||
|
||||
@@ -24,68 +26,6 @@ def test_move_works(acfactory):
|
||||
assert msg.text == "message1"
|
||||
|
||||
|
||||
def test_move_avoids_loop(acfactory, direct_imap):
|
||||
"""Test that the message is only moved from INBOX to DeltaChat.
|
||||
|
||||
This is to avoid busy loop if moved message reappears in the Inbox
|
||||
or some scanned folder later.
|
||||
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
|
||||
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
|
||||
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
|
||||
"""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.bring_online()
|
||||
|
||||
# Create INBOX.DeltaChat folder and make sure
|
||||
# it is detected by full folder scan.
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("INBOX.DeltaChat")
|
||||
ac2.stop_io()
|
||||
ac2.start_io()
|
||||
|
||||
while True:
|
||||
event = ac2.wait_for_event()
|
||||
# Wait until the end of folder scan.
|
||||
if event.kind == EventType.INFO and "Found folders:" in event.msg:
|
||||
break
|
||||
|
||||
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
ac1_chat.send_text("Message 1")
|
||||
|
||||
# Message is moved to the DeltaChat folder and downloaded.
|
||||
ac2_msg1 = ac2.wait_for_incoming_msg().get_snapshot()
|
||||
assert ac2_msg1.text == "Message 1"
|
||||
|
||||
# Move the message to the INBOX.DeltaChat again.
|
||||
# We assume that test server uses "." as the delimiter.
|
||||
ac2_direct_imap.select_folder("DeltaChat")
|
||||
ac2_direct_imap.conn.move(["*"], "INBOX.DeltaChat")
|
||||
|
||||
ac1_chat.send_text("Message 2")
|
||||
ac2_msg2 = ac2.wait_for_incoming_msg().get_snapshot()
|
||||
assert ac2_msg2.text == "Message 2"
|
||||
|
||||
# Stop and start I/O to trigger folder scan.
|
||||
ac2.stop_io()
|
||||
ac2.start_io()
|
||||
while True:
|
||||
event = ac2.wait_for_event()
|
||||
# Wait until the end of folder scan.
|
||||
if event.kind == EventType.INFO and "Found folders:" in event.msg:
|
||||
break
|
||||
|
||||
# Check that Message 1 is still in the INBOX.DeltaChat folder
|
||||
# and Message 2 is in the DeltaChat folder.
|
||||
ac2_direct_imap.select_folder("INBOX")
|
||||
assert len(ac2_direct_imap.get_all_messages()) == 0
|
||||
ac2_direct_imap.select_folder("DeltaChat")
|
||||
assert len(ac2_direct_imap.get_all_messages()) == 1
|
||||
ac2_direct_imap.select_folder("INBOX.DeltaChat")
|
||||
assert len(ac2_direct_imap.get_all_messages()) == 1
|
||||
|
||||
|
||||
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
|
||||
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
|
||||
@@ -97,6 +37,8 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
addr, password = acfactory.get_credentials()
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
assert ac2.is_configured()
|
||||
|
||||
@@ -130,174 +72,12 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
|
||||
|
||||
|
||||
def test_delete_deltachat_folder(acfactory, direct_imap):
|
||||
"""Test that DeltaChat folder is recreated if user deletes it manually."""
|
||||
ac1 = acfactory.new_configured_account()
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.conn.folder.delete("DeltaChat")
|
||||
assert "DeltaChat" not in ac1_direct_imap.list_folders()
|
||||
|
||||
# Wait until new folder is created and UIDVALIDITY is updated.
|
||||
while True:
|
||||
event = ac1.wait_for_event()
|
||||
if event.kind == EventType.INFO and "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
|
||||
break
|
||||
|
||||
ac2 = acfactory.get_online_account()
|
||||
ac2.create_chat(ac1).send_text("hello")
|
||||
msg = ac1.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
|
||||
assert "DeltaChat" in ac1_direct_imap.list_folders()
|
||||
|
||||
|
||||
def test_dont_show_emails(acfactory, direct_imap, log):
|
||||
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
|
||||
So: If it's outgoing AND there is no Received header, then ignore the email.
|
||||
|
||||
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
|
||||
|
||||
Also, test that unknown emails in the Spam folder are not shown."""
|
||||
ac1 = acfactory.new_configured_account()
|
||||
ac1.stop_io()
|
||||
ac1.set_config("show_emails", "2")
|
||||
|
||||
ac1.create_contact("alice@example.org").create_chat()
|
||||
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("Drafts")
|
||||
ac1_direct_imap.create_folder("Spam")
|
||||
ac1_direct_imap.create_folder("Junk")
|
||||
|
||||
# Learn UID validity for all folders.
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
ac1.start_io()
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
ac1.stop_io()
|
||||
|
||||
ac1_direct_imap.append(
|
||||
"Drafts",
|
||||
"""
|
||||
From: ac1 <{}>
|
||||
Subject: subj
|
||||
To: alice@example.org
|
||||
Message-ID: <aepiors@example.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
message in Drafts received later
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: unknown.address@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: unknown.address@junk.org, unkwnown.add@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message2@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown & malformed message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: delta<address: inbox@nhroy.com>
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message99@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown & malformed message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Spam",
|
||||
"""
|
||||
From: alice@example.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message3@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Actually interesting message in Spam
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
ac1_direct_imap.append(
|
||||
"Junk",
|
||||
"""
|
||||
From: unknown.address@junk.org
|
||||
Subject: subj
|
||||
To: {}
|
||||
Message-ID: <spam.message@junk.org>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Unknown message in Junk
|
||||
""".format(
|
||||
ac1.get_config("configured_addr"),
|
||||
),
|
||||
)
|
||||
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
log.section("All prepared, now let DC find the message")
|
||||
ac1.start_io()
|
||||
|
||||
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
|
||||
fresh_msgs = list(ac1.get_fresh_messages())
|
||||
msg = fresh_msgs[0].get_snapshot()
|
||||
chat_msgs = msg.chat.get_messages()
|
||||
assert len(chat_msgs) == 1
|
||||
assert msg.text == "subj – Actually interesting message in Spam"
|
||||
|
||||
assert not any("unknown.address" in c.get_full_snapshot().name for c in ac1.get_chatlist())
|
||||
ac1_direct_imap.select_folder("Spam")
|
||||
assert ac1_direct_imap.get_uid_by_message_id("spam.message@junk.org")
|
||||
|
||||
ac1.stop_io()
|
||||
log.section("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
|
||||
ac1_direct_imap.select_folder("Drafts")
|
||||
uid = ac1_direct_imap.get_uid_by_message_id("aepiors@example.org")
|
||||
ac1_direct_imap.conn.move(uid, "Inbox")
|
||||
|
||||
ac1.start_io()
|
||||
event = ac1.wait_for_event(EventType.MSGS_CHANGED)
|
||||
msg2 = Message(ac1, event.msg_id).get_snapshot()
|
||||
|
||||
assert msg2.text == "subj – message in Drafts received later"
|
||||
assert len(msg.chat.get_messages()) == 2
|
||||
|
||||
|
||||
def test_move_works_on_self_sent(acfactory):
|
||||
def test_move_works_on_self_sent(acfactory, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
# Enable movebox and wait until it is created.
|
||||
# Create and enable movebox.
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("DeltaChat")
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1.bring_online()
|
||||
@@ -314,6 +94,8 @@ def test_move_works_on_self_sent(acfactory):
|
||||
def test_moved_markseen(acfactory, direct_imap):
|
||||
"""Test that message already moved to DeltaChat folder is marked as seen."""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("DeltaChat")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
|
||||
@@ -356,6 +138,8 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
for ac in ac1, ac2:
|
||||
ac.set_config("delete_server_after", "0")
|
||||
if mvbox_move:
|
||||
ac_direct_imap = direct_imap(ac)
|
||||
ac_direct_imap.create_folder("DeltaChat")
|
||||
ac.set_config("mvbox_move", "1")
|
||||
ac.bring_online()
|
||||
|
||||
@@ -390,120 +174,12 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
|
||||
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
|
||||
|
||||
|
||||
def test_mvbox_and_trash(acfactory, direct_imap, log):
|
||||
log.section("ac1: start with mvbox")
|
||||
ac1 = acfactory.get_online_account()
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
log.section("ac2: start without a mvbox")
|
||||
ac2 = acfactory.get_online_account()
|
||||
|
||||
log.section("ac1: create trash")
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder("Trash")
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
ac1.stop_io()
|
||||
ac1.start_io()
|
||||
|
||||
log.section("ac1: send message and wait for ac2 to receive it")
|
||||
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
|
||||
assert ac2.wait_for_incoming_msg().get_snapshot().text == "message1"
|
||||
|
||||
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
|
||||
while ac1.get_config("configured_trash_folder") != "Trash":
|
||||
ac1.wait_for_event(EventType.CONNECTIVITY_CHANGED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("folder", "move", "expected_destination"),
|
||||
[
|
||||
(
|
||||
"xyz",
|
||||
False,
|
||||
"xyz",
|
||||
), # Test that emails aren't found in a random folder
|
||||
(
|
||||
"xyz",
|
||||
True,
|
||||
"xyz",
|
||||
), # ...emails are found in a random folder and downloaded without moving
|
||||
(
|
||||
"Spam",
|
||||
False,
|
||||
"INBOX",
|
||||
), # ...emails are moved from the spam folder to the Inbox
|
||||
],
|
||||
)
|
||||
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
|
||||
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
|
||||
def test_scan_folders(acfactory, log, direct_imap, folder, move, expected_destination):
|
||||
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
|
||||
variant = folder + "-" + str(move) + "-" + expected_destination
|
||||
log.section("Testing variant " + variant)
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1.set_config("delete_server_after", "0")
|
||||
if move:
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
ac1.stop_io()
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.create_folder(folder)
|
||||
|
||||
# Wait until each folder was selected once and we are IDLEing:
|
||||
ac1.start_io()
|
||||
ac1.bring_online()
|
||||
|
||||
ac1.stop_io()
|
||||
assert folder in ac1_direct_imap.list_folders()
|
||||
|
||||
log.section("Send a message from ac2 to ac1 and manually move it to `folder`")
|
||||
ac1_direct_imap.select_config_folder("inbox")
|
||||
with ac1_direct_imap.idle() as idle1:
|
||||
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
|
||||
idle1.wait_for_new_message()
|
||||
ac1_direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
|
||||
|
||||
log.section("start_io() and see if DeltaChat finds the message (" + variant + ")")
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
ac1.start_io()
|
||||
chat = ac1.create_chat(ac2)
|
||||
n_msgs = 1 # "Messages are end-to-end encrypted."
|
||||
if folder == "Spam":
|
||||
msg = ac1.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
n_msgs += 1
|
||||
else:
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
assert len(chat.get_messages()) == n_msgs
|
||||
|
||||
# The message has reached its destination.
|
||||
ac1_direct_imap.select_folder(expected_destination)
|
||||
assert len(ac1_direct_imap.get_all_messages()) == 1
|
||||
if folder != expected_destination:
|
||||
ac1_direct_imap.select_folder(folder)
|
||||
assert len(ac1_direct_imap.get_all_messages()) == 0
|
||||
|
||||
|
||||
def test_trash_multiple_messages(acfactory, direct_imap, log):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac2.stop_io()
|
||||
|
||||
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
|
||||
# Trash wasn't configured initially, it can't be configured later, let's check this.
|
||||
log.section("Creating trash folder")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
ac2_direct_imap.create_folder("Trash")
|
||||
ac2.set_config("delete_server_after", "0")
|
||||
ac2.set_config("sync_msgs", "0")
|
||||
ac2.set_config("delete_to_trash", "1")
|
||||
|
||||
log.section("Check that Trash can be configured initially as well")
|
||||
ac3 = ac2.clone()
|
||||
ac3.bring_online()
|
||||
assert ac3.get_config("configured_trash_folder")
|
||||
ac3.stop_io()
|
||||
|
||||
ac2.start_io()
|
||||
chat12 = acfactory.get_accepted_chat(ac1, ac2)
|
||||
@@ -520,17 +196,15 @@ def test_trash_multiple_messages(acfactory, direct_imap, log):
|
||||
assert msg.text in texts
|
||||
if text != "second":
|
||||
to_delete.append(msg)
|
||||
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
|
||||
# check the configuration.
|
||||
assert ac2.get_config("configured_trash_folder") == "Trash"
|
||||
|
||||
log.section("ac2: deleting all messages except second")
|
||||
assert len(to_delete) == len(texts) - 1
|
||||
ac2.delete_messages(to_delete)
|
||||
|
||||
log.section("ac2: test that only one message is left")
|
||||
ac2_direct_imap = direct_imap(ac2)
|
||||
while 1:
|
||||
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
|
||||
ac2.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
|
||||
ac2_direct_imap.select_config_folder("inbox")
|
||||
nr_msgs = len(ac2_direct_imap.get_all_messages())
|
||||
assert nr_msgs > 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import EventType
|
||||
from deltachat_rpc_client.const import DownloadState
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
|
||||
|
||||
@@ -37,8 +38,8 @@ def test_add_second_address(acfactory) -> None:
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.set_config(option, "1")
|
||||
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.set_config("show_emails", "0")
|
||||
# show_emails does not matter for multi-relay, can be set to anything
|
||||
account.set_config("show_emails", "0")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
|
||||
@@ -57,8 +58,8 @@ def test_no_second_transport_with_mvbox(acfactory, key) -> None:
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_no_second_transport_without_classic_emails(acfactory) -> None:
|
||||
"""Test that second transport cannot be configured if classic emails are not fetched."""
|
||||
def test_second_transport_without_classic_emails(acfactory) -> None:
|
||||
"""Test that second transport can be configured if classic emails are not fetched."""
|
||||
account = acfactory.new_configured_account()
|
||||
assert len(account.list_transports()) == 1
|
||||
|
||||
@@ -67,8 +68,7 @@ def test_no_second_transport_without_classic_emails(acfactory) -> None:
|
||||
qr = acfactory.get_account_qr()
|
||||
account.set_config("show_emails", "0")
|
||||
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.add_transport_from_qr(qr)
|
||||
account.add_transport_from_qr(qr)
|
||||
|
||||
|
||||
def test_change_address(acfactory) -> None:
|
||||
@@ -120,6 +120,33 @@ def test_change_address(acfactory) -> None:
|
||||
assert sender_addr2 == new_alice_addr
|
||||
|
||||
|
||||
def test_download_on_demand(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
alice.set_config("download_limit", "1")
|
||||
|
||||
alice.stop_io()
|
||||
qr = acfactory.get_account_qr()
|
||||
alice.add_transport_from_qr(qr)
|
||||
alice.start_io()
|
||||
|
||||
alice.create_chat(bob)
|
||||
chat_bob_alice = bob.create_chat(alice)
|
||||
chat_bob_alice.send_message(file="../test-data/image/screenshot.jpg")
|
||||
msg = alice.wait_for_incoming_msg()
|
||||
snapshot = msg.get_snapshot()
|
||||
assert snapshot.download_state == DownloadState.AVAILABLE
|
||||
chat_id = snapshot.chat_id
|
||||
# Actually the message isn't available yet. Wait somehow for the post-message to arrive.
|
||||
chat_bob_alice.send_message("Now you can download my previous message")
|
||||
alice.wait_for_incoming_msg()
|
||||
alice._rpc.download_full_message(alice.id, msg.id)
|
||||
for dstate in [DownloadState.IN_PROGRESS, DownloadState.DONE]:
|
||||
event = alice.wait_for_event(EventType.MSGS_CHANGED)
|
||||
assert event.chat_id == chat_id
|
||||
assert event.msg_id == msg.id
|
||||
assert msg.get_snapshot().download_state == dstate
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
|
||||
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
|
||||
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
|
||||
@@ -209,7 +236,7 @@ def test_transport_synchronization(acfactory, log) -> None:
|
||||
|
||||
def test_transport_sync_new_as_primary(acfactory, log) -> None:
|
||||
"""Test synchronization of new transport as primary between devices."""
|
||||
ac1 = acfactory.get_online_account()
|
||||
ac1, bob = acfactory.get_online_accounts(2)
|
||||
ac1_clone = ac1.clone()
|
||||
ac1_clone.bring_online()
|
||||
|
||||
@@ -229,6 +256,15 @@ def test_transport_sync_new_as_primary(acfactory, log) -> None:
|
||||
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
|
||||
assert ac1_clone.get_config("configured_addr") == transport2["addr"]
|
||||
|
||||
log.section("ac1_clone receives a message via the new primary transport")
|
||||
ac1_chat = ac1.create_chat(bob)
|
||||
ac1_chat.send_text("Hello!")
|
||||
bob_chat_id = bob.wait_for_incoming_msg_event().chat_id
|
||||
bob_chat = bob.get_chat_by_id(bob_chat_id)
|
||||
bob_chat.accept()
|
||||
bob_chat.send_text("hello back")
|
||||
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "hello back"
|
||||
|
||||
|
||||
def test_recognize_self_address(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -387,22 +388,29 @@ def test_dont_move_sync_msgs(acfactory, direct_imap):
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1.set_config("fix_is_chatmail", "1")
|
||||
ac1.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac1.bring_online()
|
||||
ac1.start_io()
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
|
||||
ac1_direct_imap.select_folder("Inbox")
|
||||
# Sync messages may also be sent during configuration.
|
||||
inbox_msg_cnt = len(ac1_direct_imap.get_all_messages())
|
||||
ac1.wait_for_event(EventType.MSG_DELIVERED)
|
||||
ac1_direct_imap.select_folder("Inbox")
|
||||
while True:
|
||||
if len(ac1_direct_imap.get_all_messages()) == 1:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
ac1.set_config("displayname", "Alice")
|
||||
ac1.wait_for_event(EventType.MSG_DELIVERED)
|
||||
ac1.set_config("displayname", "Bob")
|
||||
ac1.wait_for_event(EventType.MSG_DELIVERED)
|
||||
ac1_direct_imap.select_folder("Inbox")
|
||||
assert len(ac1_direct_imap.get_all_messages()) == inbox_msg_cnt + 2
|
||||
|
||||
ac1_direct_imap.select_folder("DeltaChat")
|
||||
assert len(ac1_direct_imap.get_all_messages()) == 0
|
||||
# Message may not be delivered to IMAP immediately
|
||||
# after sending over SMTP,
|
||||
# retry until they are delivered to IMAP.
|
||||
while True:
|
||||
if len(ac1_direct_imap.get_all_messages()) == 3:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def test_reaction_seen_on_another_dev(acfactory) -> None:
|
||||
@@ -911,6 +919,47 @@ def test_markseen_contact_request(acfactory):
|
||||
assert message2.get_snapshot().state == MessageState.IN_SEEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_profile", [True, False])
|
||||
def test_no_markseen_in_team_profile(team_profile, acfactory):
|
||||
"""
|
||||
Test that seen status is synchronized iff `team_profile` isn't set.
|
||||
"""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
if team_profile:
|
||||
bob.set_config("team_profile", "1")
|
||||
|
||||
# Bob sets up a second device.
|
||||
bob2 = bob.clone()
|
||||
bob2.start_io()
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
bob_chat_alice = bob.create_chat(alice)
|
||||
bob2.create_chat(alice)
|
||||
alice_chat_bob.send_text("Hello Bob!")
|
||||
|
||||
message = bob.wait_for_incoming_msg()
|
||||
message2 = bob2.wait_for_incoming_msg()
|
||||
assert message2.get_snapshot().state == MessageState.IN_FRESH
|
||||
|
||||
message.mark_seen()
|
||||
|
||||
# Send a message and wait until it arrives
|
||||
# in order to wait until Bob2 gets the markseen message.
|
||||
# This also tests that outgoing messages
|
||||
# don't mark preceeding messages as seen in team profiles.
|
||||
bob_chat_alice.send_text("Outgoing message")
|
||||
while True:
|
||||
outgoing = bob2.wait_for_msg(EventType.MSGS_CHANGED)
|
||||
if outgoing.id != 0:
|
||||
break
|
||||
assert outgoing.get_snapshot().text == "Outgoing message"
|
||||
|
||||
if team_profile:
|
||||
assert message2.get_snapshot().state == MessageState.IN_FRESH
|
||||
else:
|
||||
assert message2.get_snapshot().state == MessageState.IN_SEEN
|
||||
|
||||
|
||||
def test_read_receipt(acfactory):
|
||||
"""
|
||||
Test sending a read receipt and ensure it is attributed to the correct contact.
|
||||
@@ -930,6 +979,9 @@ def test_read_receipt(acfactory):
|
||||
assert len(read_receipts) == 1
|
||||
assert read_receipts[0].contact_id == alice_contact_bob.id
|
||||
|
||||
read_receipt_cnt = read_msg.get_read_receipt_count()
|
||||
assert read_receipt_cnt == 1
|
||||
|
||||
|
||||
def test_get_http_response(acfactory):
|
||||
alice = acfactory.new_configured_account()
|
||||
@@ -1133,6 +1185,30 @@ def test_leave_broadcast(acfactory, all_devices_online):
|
||||
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
|
||||
|
||||
|
||||
def test_leave_and_delete_group(acfactory, log):
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
log.section("Alice creates a group")
|
||||
alice_chat = alice.create_group("Group")
|
||||
alice_chat.add_contact(bob)
|
||||
assert len(alice_chat.get_contacts()) == 2 # Alice and Bob
|
||||
alice_chat.send_text("hello")
|
||||
|
||||
log.section("Bob sees the group, and leaves and deletes it")
|
||||
msg = bob.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
msg.chat.accept()
|
||||
|
||||
msg.chat.leave()
|
||||
# Bob deletes the chat. This must not prevent the leave message from being sent.
|
||||
msg.chat.delete()
|
||||
|
||||
log.section("Alice receives the delete message")
|
||||
# After Bob left, only Alice will be left in the group:
|
||||
while len(alice_chat.get_contacts()) != 1:
|
||||
alice.wait_for_event(EventType.CHAT_MODIFIED)
|
||||
|
||||
|
||||
def test_immediate_autodelete(acfactory, direct_imap, log):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.37.0"
|
||||
version = "2.42.0"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.37.0"
|
||||
"version": "2.42.0"
|
||||
}
|
||||
|
||||
@@ -17,11 +17,6 @@ ignore = [
|
||||
# It is a transitive dependency of iroh 0.35.0,
|
||||
# this should be fixed by upgrading to iroh 1.0 once it is released.
|
||||
"RUSTSEC-2025-0134",
|
||||
|
||||
# Old versions of "lru" are transitive dependencies of iroh 0.35.0.
|
||||
# <https://rustsec.org/advisories/RUSTSEC-2026-0002>
|
||||
# <https://github.com/chatmail/core/issues/7692>
|
||||
"RUSTSEC-2026-0002",
|
||||
]
|
||||
|
||||
[bans]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.37.0"
|
||||
version = "2.42.0"
|
||||
license = "MPL-2.0"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
|
||||
@@ -220,21 +220,6 @@ def test_webxdc_huge_update(acfactory, data, lp):
|
||||
assert update["payload"] == payload
|
||||
|
||||
|
||||
def test_enable_mvbox_move(acfactory, lp):
|
||||
(ac1,) = acfactory.get_online_accounts(1)
|
||||
|
||||
lp.sec("ac2: start without mvbox thread")
|
||||
ac2 = acfactory.new_online_configuring_account(mvbox_move=False)
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
lp.sec("ac2: configuring mvbox")
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
|
||||
lp.sec("ac1: send message and wait for ac2 to receive it")
|
||||
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
|
||||
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
|
||||
|
||||
|
||||
def test_forward_messages(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
chat = ac1.create_chat(ac2)
|
||||
@@ -350,7 +335,7 @@ def test_long_group_name(acfactory, lp):
|
||||
|
||||
|
||||
def test_send_self_message(acfactory, lp):
|
||||
ac1 = acfactory.new_online_configuring_account(mvbox_move=True, bcc_self=True)
|
||||
ac1 = acfactory.new_online_configuring_account(bcc_self=True)
|
||||
acfactory.bring_accounts_online()
|
||||
lp.sec("ac1: create self chat")
|
||||
chat = ac1.get_self_contact().create_chat()
|
||||
@@ -509,7 +494,7 @@ def test_reply_privately(acfactory):
|
||||
|
||||
|
||||
def test_mdn_asymmetric(acfactory, lp):
|
||||
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
|
||||
ac1 = acfactory.new_online_configuring_account()
|
||||
ac2 = acfactory.new_online_configuring_account()
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
@@ -538,20 +523,14 @@ def test_mdn_asymmetric(acfactory, lp):
|
||||
ac2.mark_seen_messages([msg])
|
||||
|
||||
lp.sec("ac1: waiting for incoming activity")
|
||||
# MDN should be moved even though MDNs are already disabled
|
||||
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
|
||||
|
||||
assert len(chat.get_messages()) == 1 + E2EE_INFO_MSGS
|
||||
|
||||
# Wait for the message to be marked as seen on IMAP.
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
|
||||
# MDN is received even though MDNs are already disabled
|
||||
assert msg_out.is_out_mdn_received()
|
||||
|
||||
ac1.direct_imap.select_config_folder("mvbox")
|
||||
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
|
||||
|
||||
|
||||
def test_send_receive_encrypt(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
@@ -953,7 +932,6 @@ def test_set_get_group_image(acfactory, data, lp):
|
||||
|
||||
def test_connectivity(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1.set_config("scan_all_folders_debounce_secs", "0")
|
||||
|
||||
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_CONNECTED)
|
||||
|
||||
|
||||
@@ -258,9 +258,6 @@ class TestOfflineChat:
|
||||
with pytest.raises(ValueError):
|
||||
ac1.set_stock_translation(dc.const.DC_STR_FILE, "xyz %1$s")
|
||||
ac1._evtracker.get_matching("DC_EVENT_WARNING")
|
||||
with pytest.raises(ValueError):
|
||||
ac1.set_stock_translation(dc.const.DC_STR_CONTACT_NOT_VERIFIED, "xyz %2$s")
|
||||
ac1._evtracker.get_matching("DC_EVENT_WARNING")
|
||||
with pytest.raises(ValueError):
|
||||
ac1.set_stock_translation(500, "xyz %1$s")
|
||||
ac1._evtracker.get_matching("DC_EVENT_WARNING")
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-01-08
|
||||
2026-02-10
|
||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
#
|
||||
# Avoid using rustup here as it depends on reading /proc/self/exe and
|
||||
# has problems running under QEMU.
|
||||
RUST_VERSION=1.92.0
|
||||
RUST_VERSION=1.93.0
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# Update package cache without changing the lockfile.
|
||||
cargo update --dry-run
|
||||
|
||||
cargo deny --workspace --all-features check -D warnings
|
||||
cargo deny --workspace --all-features --locked check -D warnings
|
||||
|
||||
@@ -6,11 +6,11 @@ set -euo pipefail
|
||||
export TZ=UTC
|
||||
|
||||
# Provider database revision.
|
||||
REV=d041136c19a48b493823b46d472f12b9ee94ae80
|
||||
REV=996c4bc82be5a7404f70b185ff062da33bfa98d9
|
||||
|
||||
CORE_ROOT="$PWD"
|
||||
TMP="$(mktemp -d)"
|
||||
git clone --filter=blob:none https://github.com/deltachat/provider-db.git "$TMP"
|
||||
git clone --filter=blob:none https://github.com/chatmail/provider-db.git "$TMP"
|
||||
cd "$TMP"
|
||||
git checkout "$REV"
|
||||
DATE=$(git show -s --format=%cs)
|
||||
|
||||
@@ -110,9 +110,9 @@ impl FromStr for Aheader {
|
||||
SignedPublicKey::from_base64(&raw).context("autocrypt key cannot be decoded")
|
||||
})
|
||||
.and_then(|key| {
|
||||
key.verify()
|
||||
key.verify_bindings()
|
||||
.and(Ok(key))
|
||||
.context("autocrypt key cannot be verified")
|
||||
.context("Autocrypt key cannot be verified")
|
||||
})?;
|
||||
|
||||
let prefer_encrypt = attributes
|
||||
|
||||
48
src/blob.rs
48
src/blob.rs
@@ -1,6 +1,6 @@
|
||||
//! # Blob directory management.
|
||||
|
||||
use core::cmp::max;
|
||||
use std::cmp::max;
|
||||
use std::io::{Cursor, Seek};
|
||||
use std::iter::FusedIterator;
|
||||
use std::mem;
|
||||
@@ -256,7 +256,7 @@ impl<'a> BlobObject<'a> {
|
||||
|
||||
/// Recode image to avatar size.
|
||||
pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
|
||||
let (img_wh, max_bytes) =
|
||||
let (max_wh, max_bytes) =
|
||||
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
@@ -273,7 +273,7 @@ impl<'a> BlobObject<'a> {
|
||||
let is_avatar = true;
|
||||
self.check_or_recode_to_size(
|
||||
context, None, // The name of an avatar doesn't matter
|
||||
viewtype, img_wh, max_bytes, is_avatar,
|
||||
viewtype, max_wh, max_bytes, is_avatar,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@@ -294,7 +294,7 @@ impl<'a> BlobObject<'a> {
|
||||
name: Option<String>,
|
||||
viewtype: &mut Viewtype,
|
||||
) -> Result<String> {
|
||||
let (img_wh, max_bytes) =
|
||||
let (max_wh, max_bytes) =
|
||||
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
@@ -305,13 +305,15 @@ impl<'a> BlobObject<'a> {
|
||||
MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
|
||||
};
|
||||
let is_avatar = false;
|
||||
self.check_or_recode_to_size(context, name, viewtype, img_wh, max_bytes, is_avatar)
|
||||
self.check_or_recode_to_size(context, name, viewtype, max_wh, max_bytes, is_avatar)
|
||||
}
|
||||
|
||||
/// Checks or recodes the image so that it fits into limits on width/height and byte size.
|
||||
/// Checks or recodes the image so that it fits into limits on width/height and/or byte size.
|
||||
///
|
||||
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `img_wh` and proceeds
|
||||
/// with the result without rechecking.
|
||||
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `max_wh` and proceeds
|
||||
/// with the result (even if `max_bytes` is still exceeded).
|
||||
///
|
||||
/// If `is_avatar`, the resolution will be reduced in a loop until the image fits `max_bytes`.
|
||||
///
|
||||
/// This modifies the blob object in-place.
|
||||
///
|
||||
@@ -324,7 +326,7 @@ impl<'a> BlobObject<'a> {
|
||||
context: &Context,
|
||||
name: Option<String>,
|
||||
viewtype: &mut Viewtype,
|
||||
mut img_wh: u32,
|
||||
max_wh: u32,
|
||||
max_bytes: usize,
|
||||
is_avatar: bool,
|
||||
) -> Result<String> {
|
||||
@@ -386,7 +388,14 @@ impl<'a> BlobObject<'a> {
|
||||
_ => img,
|
||||
};
|
||||
|
||||
let exceeds_wh = img.width() > img_wh || img.height() > img_wh;
|
||||
// max_wh is the maximum image width and height, i.e. the resolution-limit.
|
||||
// target_wh target-resolution for resizing the image.
|
||||
let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
|
||||
let mut target_wh = if exceeds_wh {
|
||||
max_wh
|
||||
} else {
|
||||
max(img.width(), img.height())
|
||||
};
|
||||
let exceeds_max_bytes = nr_bytes > max_bytes as u64;
|
||||
|
||||
let jpeg_quality = 75;
|
||||
@@ -425,15 +434,6 @@ impl<'a> BlobObject<'a> {
|
||||
});
|
||||
|
||||
if do_scale {
|
||||
if !exceeds_wh {
|
||||
img_wh = max(img.width(), img.height());
|
||||
// PNGs and WebPs may be huge because of animation, which is lost by the `image`
|
||||
// crate when recoding, so don't scale them down.
|
||||
if matches!(fmt, ImageFormat::Jpeg) || !encoded.is_empty() {
|
||||
img_wh = img_wh * 2 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
if mem::take(&mut add_white_bg) {
|
||||
self::add_white_bg(&mut img);
|
||||
@@ -448,9 +448,9 @@ impl<'a> BlobObject<'a> {
|
||||
// usually has less pixels by cropping, UI that needs to wait anyways,
|
||||
// and also benefits from slightly better (5%) encoding of Triangle-filtered images.
|
||||
let new_img = if is_avatar {
|
||||
img.resize(img_wh, img_wh, image::imageops::FilterType::Triangle)
|
||||
img.resize(target_wh, target_wh, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
img.thumbnail(img_wh, img_wh)
|
||||
img.thumbnail(target_wh, target_wh)
|
||||
};
|
||||
|
||||
if encoded_img_exceeds_bytes(
|
||||
@@ -461,19 +461,19 @@ impl<'a> BlobObject<'a> {
|
||||
&mut encoded,
|
||||
)? && is_avatar
|
||||
{
|
||||
if img_wh < 20 {
|
||||
if target_wh < 20 {
|
||||
return Err(format_err!(
|
||||
"Failed to scale image to below {max_bytes}B.",
|
||||
));
|
||||
}
|
||||
|
||||
img_wh = img_wh * 2 / 3;
|
||||
target_wh = target_wh * 2 / 3;
|
||||
} else {
|
||||
info!(
|
||||
context,
|
||||
"Final scaled-down image size: {}B ({}px).",
|
||||
encoded.len(),
|
||||
img_wh
|
||||
target_wh
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -798,3 +798,56 @@ async fn test_create_and_deduplicate_from_bytes() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that an image that already fits into the width limit,
|
||||
/// but not the bytes limit,
|
||||
/// is compressed without changing the resolution.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recode_without_downscaling() -> Result<()> {
|
||||
let t = &TestContext::new().await;
|
||||
|
||||
let image = include_bytes!("../../test-data/image/screenshot120x120.jpg");
|
||||
const { assert!(120 < constants::WORSE_AVATAR_SIZE) };
|
||||
|
||||
for is_avatar in [true, false] {
|
||||
let mut blob =
|
||||
BlobObject::create_and_deduplicate_from_bytes(t, image, "image.jpg").unwrap();
|
||||
let image_path = blob.to_abs_path();
|
||||
check_image_size(&image_path, 120, 120);
|
||||
|
||||
assert!(
|
||||
fs::metadata(&image_path).await.unwrap().len() > constants::WORSE_AVATAR_BYTES as u64
|
||||
);
|
||||
|
||||
// Repeat the check, because a second call to `check_or_recode_to_size()`
|
||||
// is not supposed to change anything:
|
||||
let mut imgs = vec![];
|
||||
for _ in 0..2 {
|
||||
let mut viewtype = Viewtype::Image;
|
||||
let new_name = blob.check_or_recode_to_size(
|
||||
t,
|
||||
Some("image.jpg".to_string()),
|
||||
&mut viewtype,
|
||||
constants::WORSE_AVATAR_SIZE,
|
||||
constants::WORSE_AVATAR_BYTES,
|
||||
is_avatar,
|
||||
)?;
|
||||
let image_path = blob.to_abs_path();
|
||||
assert_eq!(new_name, "image.jpg"); // The name shall not have changed
|
||||
assert_eq!(viewtype, Viewtype::Image); // The viewtype shall not have changed
|
||||
let img = check_image_size(&image_path, 120, 120); // The resolution shall not have changed
|
||||
imgs.push(img);
|
||||
|
||||
let new_image_bytes = fs::metadata(&image_path).await.unwrap().len();
|
||||
assert!(
|
||||
new_image_bytes < constants::WORSE_AVATAR_BYTES as u64,
|
||||
"The new image size, {new_image_bytes}, should be lower than {}, is_avatar={is_avatar}",
|
||||
constants::WORSE_AVATAR_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(imgs[0], imgs[1]);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
250
src/calls.rs
250
src/calls.rs
@@ -15,13 +15,12 @@ use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::net::dns::lookup_host_with_cache;
|
||||
use crate::param::Param;
|
||||
use crate::stock_str;
|
||||
use crate::tools::{normalize_text, time};
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use deltachat_derive::{FromSql, ToSql};
|
||||
use num_traits::FromPrimitive;
|
||||
use sdp::SessionDescription;
|
||||
use serde::Serialize;
|
||||
use std::io::Cursor;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use tokio::task;
|
||||
@@ -104,10 +103,14 @@ impl CallInfo {
|
||||
};
|
||||
|
||||
if self.is_incoming() {
|
||||
self.update_text(context, &format!("Incoming call\n{duration}"))
|
||||
let incoming_call_str =
|
||||
stock_str::incoming_call(context, self.has_video_initially()).await;
|
||||
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
|
||||
.await?;
|
||||
} else {
|
||||
self.update_text(context, &format!("Outgoing call\n{duration}"))
|
||||
let outgoing_call_str =
|
||||
stock_str::outgoing_call(context, self.has_video_initially()).await;
|
||||
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -126,6 +129,14 @@ impl CallInfo {
|
||||
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
|
||||
}
|
||||
|
||||
/// Returns true if the call is started as a video call.
|
||||
pub fn has_video_initially(&self) -> bool {
|
||||
self.msg
|
||||
.param
|
||||
.get_bool(Param::WebrtcHasVideoInitially)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns true if the call is missed
|
||||
/// because the caller canceled it
|
||||
/// explicitly before ringing stopped.
|
||||
@@ -185,6 +196,7 @@ impl Context {
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
place_call_info: String,
|
||||
has_video_initially: bool,
|
||||
) -> Result<MsgId> {
|
||||
let chat = Chat::load_from_db(self, chat_id).await?;
|
||||
ensure!(
|
||||
@@ -193,12 +205,15 @@ impl Context {
|
||||
);
|
||||
ensure!(!chat.is_self_talk(), "Cannot call self");
|
||||
|
||||
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially).await;
|
||||
let mut call = Message {
|
||||
viewtype: Viewtype::Call,
|
||||
text: "Outgoing call".into(),
|
||||
text: outgoing_call_str,
|
||||
..Default::default()
|
||||
};
|
||||
call.param.set(Param::WebrtcRoom, &place_call_info);
|
||||
call.param
|
||||
.set_int(Param::WebrtcHasVideoInitially, has_video_initially.into());
|
||||
call.id = send_msg(self, chat_id, &mut call).await?;
|
||||
|
||||
let wait = RINGING_SECONDS;
|
||||
@@ -266,10 +281,12 @@ impl Context {
|
||||
if !call.is_accepted() {
|
||||
if call.is_incoming() {
|
||||
call.mark_as_ended(self).await?;
|
||||
call.update_text(self, "Declined call").await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_canceled(self).await?;
|
||||
call.update_text(self, "Canceled call").await?;
|
||||
let canceled_call_str = stock_str::canceled_call(self).await;
|
||||
call.update_text(self, &canceled_call_str).await?;
|
||||
}
|
||||
} else {
|
||||
call.mark_as_ended(self).await?;
|
||||
@@ -311,10 +328,12 @@ impl Context {
|
||||
if !call.is_accepted() && !call.is_ended() {
|
||||
if call.is_incoming() {
|
||||
call.mark_as_canceled(&context).await?;
|
||||
call.update_text(&context, "Missed call").await?;
|
||||
let missed_call_str = stock_str::missed_call(&context).await;
|
||||
call.update_text(&context, &missed_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_ended(&context).await?;
|
||||
call.update_text(&context, "Canceled call").await?;
|
||||
let canceled_call_str = stock_str::canceled_call(&context).await;
|
||||
call.update_text(&context, &canceled_call_str).await?;
|
||||
}
|
||||
context.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
context.emit_event(EventType::CallEnded {
|
||||
@@ -339,18 +358,14 @@ impl Context {
|
||||
|
||||
if call.is_incoming() {
|
||||
if call.is_stale() {
|
||||
call.update_text(self, "Missed call").await?;
|
||||
let missed_call_str = stock_str::missed_call(self).await;
|
||||
call.update_text(self, &missed_call_str).await?;
|
||||
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
|
||||
} else {
|
||||
call.update_text(self, "Incoming call").await?;
|
||||
let incoming_call_str =
|
||||
stock_str::incoming_call(self, call.has_video_initially()).await;
|
||||
call.update_text(self, &incoming_call_str).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
|
||||
let has_video = match sdp_has_video(&call.place_call_info) {
|
||||
Ok(has_video) => has_video,
|
||||
Err(err) => {
|
||||
warn!(self, "Failed to determine if SDP offer has video: {err:#}.");
|
||||
false
|
||||
}
|
||||
};
|
||||
let can_call_me = match who_can_call_me(self).await? {
|
||||
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
|
||||
.await?
|
||||
@@ -377,7 +392,7 @@ impl Context {
|
||||
msg_id: call.msg.id,
|
||||
chat_id: call.msg.chat_id,
|
||||
place_call_info: call.place_call_info.to_string(),
|
||||
has_video,
|
||||
has_video: call.has_video_initially(),
|
||||
});
|
||||
}
|
||||
let wait = call.remaining_ring_seconds();
|
||||
@@ -389,7 +404,9 @@ impl Context {
|
||||
));
|
||||
}
|
||||
} else {
|
||||
call.update_text(self, "Outgoing call").await?;
|
||||
let outgoing_call_str =
|
||||
stock_str::outgoing_call(self, call.has_video_initially()).await;
|
||||
call.update_text(self, &outgoing_call_str).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
}
|
||||
} else {
|
||||
@@ -439,19 +456,23 @@ impl Context {
|
||||
if call.is_incoming() {
|
||||
if from_id == ContactId::SELF {
|
||||
call.mark_as_ended(self).await?;
|
||||
call.update_text(self, "Declined call").await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_canceled(self).await?;
|
||||
call.update_text(self, "Missed call").await?;
|
||||
let missed_call_str = stock_str::missed_call(self).await;
|
||||
call.update_text(self, &missed_call_str).await?;
|
||||
}
|
||||
} else {
|
||||
// outgoing
|
||||
if from_id == ContactId::SELF {
|
||||
call.mark_as_canceled(self).await?;
|
||||
call.update_text(self, "Canceled call").await?;
|
||||
let canceled_call_str = stock_str::canceled_call(self).await;
|
||||
call.update_text(self, &canceled_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_ended(self).await?;
|
||||
call.update_text(self, "Declined call").await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -507,19 +528,6 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if SDP offer has a video.
|
||||
pub fn sdp_has_video(sdp: &str) -> Result<bool> {
|
||||
let mut cursor = Cursor::new(sdp);
|
||||
let session_description =
|
||||
SessionDescription::unmarshal(&mut cursor).context("Failed to parse SDP")?;
|
||||
for media_description in &session_description.media_descriptions {
|
||||
if media_description.media_name.media == "video" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// State of the call for display in the message bubble.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CallState {
|
||||
@@ -617,33 +625,7 @@ struct IceServer {
|
||||
pub credential: Option<String>,
|
||||
}
|
||||
|
||||
/// Creates JSON with ICE servers.
|
||||
async fn create_ice_servers(
|
||||
context: &Context,
|
||||
hostname: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String> {
|
||||
// Do not use cache because there is no TLS.
|
||||
let load_cache = false;
|
||||
let urls: Vec<String> = lookup_host_with_cache(context, hostname, port, "", load_cache)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|addr| format!("turn:{addr}"))
|
||||
.collect();
|
||||
|
||||
let ice_server = IceServer {
|
||||
urls,
|
||||
username: Some(username.to_string()),
|
||||
credential: Some(password.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&[ice_server])?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Creates JSON with ICE servers from a line received over IMAP METADATA.
|
||||
/// Creates ICE servers from a line received over IMAP METADATA.
|
||||
///
|
||||
/// IMAP METADATA returns a line such as
|
||||
/// `example.com:3478:1758650868:8Dqkyyu11MVESBqjbIylmB06rv8=`
|
||||
@@ -653,20 +635,107 @@ async fn create_ice_servers(
|
||||
/// while `8Dqkyyu11MVESBqjbIylmB06rv8=`
|
||||
/// is the password.
|
||||
pub(crate) async fn create_ice_servers_from_metadata(
|
||||
context: &Context,
|
||||
metadata: &str,
|
||||
) -> Result<(i64, String)> {
|
||||
) -> Result<(i64, Vec<UnresolvedIceServer>)> {
|
||||
let (hostname, rest) = metadata.split_once(':').context("Missing hostname")?;
|
||||
let (port, rest) = rest.split_once(':').context("Missing port")?;
|
||||
let port = u16::from_str(port).context("Failed to parse the port")?;
|
||||
let (ts, password) = rest.split_once(':').context("Missing timestamp")?;
|
||||
let expiration_timestamp = i64::from_str(ts).context("Failed to parse the timestamp")?;
|
||||
let ice_servers = create_ice_servers(context, hostname, port, ts, password).await?;
|
||||
let ice_servers = vec![UnresolvedIceServer::Turn {
|
||||
hostname: hostname.to_string(),
|
||||
port,
|
||||
username: ts.to_string(),
|
||||
credential: password.to_string(),
|
||||
}];
|
||||
Ok((expiration_timestamp, ice_servers))
|
||||
}
|
||||
|
||||
/// STUN or TURN server with unresolved DNS name.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum UnresolvedIceServer {
|
||||
/// STUN server.
|
||||
Stun { hostname: String, port: u16 },
|
||||
|
||||
/// TURN server with the username and password.
|
||||
Turn {
|
||||
hostname: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
credential: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Resolves domain names of ICE servers.
|
||||
///
|
||||
/// On failure to resolve, logs the error
|
||||
/// and skips the server, but does not fail.
|
||||
pub(crate) async fn resolve_ice_servers(
|
||||
context: &Context,
|
||||
unresolved_ice_servers: Vec<UnresolvedIceServer>,
|
||||
) -> Result<String> {
|
||||
let mut result: Vec<IceServer> = Vec::new();
|
||||
|
||||
// Do not use cache because there is no TLS.
|
||||
let load_cache = false;
|
||||
|
||||
for unresolved_ice_server in unresolved_ice_servers {
|
||||
match unresolved_ice_server {
|
||||
UnresolvedIceServer::Stun { hostname, port } => {
|
||||
match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
|
||||
Ok(addrs) => {
|
||||
let urls: Vec<String> = addrs
|
||||
.into_iter()
|
||||
.map(|addr| format!("stun:{addr}"))
|
||||
.collect();
|
||||
let stun_server = IceServer {
|
||||
urls,
|
||||
username: None,
|
||||
credential: None,
|
||||
};
|
||||
result.push(stun_server);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to resolve STUN {hostname}:{port}: {err:#}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
UnresolvedIceServer::Turn {
|
||||
hostname,
|
||||
port,
|
||||
username,
|
||||
credential,
|
||||
} => match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
|
||||
Ok(addrs) => {
|
||||
let urls: Vec<String> = addrs
|
||||
.into_iter()
|
||||
.map(|addr| format!("turn:{addr}"))
|
||||
.collect();
|
||||
let turn_server = IceServer {
|
||||
urls,
|
||||
username: Some(username),
|
||||
credential: Some(credential),
|
||||
};
|
||||
result.push(turn_server);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Failed to resolve TURN {hostname}:{port}: {err:#}."
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
let json = serde_json::to_string(&result)?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Creates JSON with ICE servers when no TURN servers are known.
|
||||
pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<String> {
|
||||
pub(crate) fn create_fallback_ice_servers() -> Vec<UnresolvedIceServer> {
|
||||
// Do not use public STUN server from https://stunprotocol.org/.
|
||||
// It changes the hostname every year
|
||||
// (e.g. stunserver2025.stunprotocol.org
|
||||
@@ -674,36 +743,18 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
|
||||
// because of bandwidth costs:
|
||||
// <https://github.com/jselbie/stunserver/issues/50>
|
||||
|
||||
let hostname = "nine.testrun.org";
|
||||
// Do not use cache because there is no TLS.
|
||||
let load_cache = false;
|
||||
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|addr| format!("stun:{addr}"))
|
||||
.collect();
|
||||
let stun_server = IceServer {
|
||||
urls,
|
||||
username: None,
|
||||
credential: None,
|
||||
};
|
||||
|
||||
let hostname = "turn.delta.chat";
|
||||
// Do not use cache because there is no TLS.
|
||||
let load_cache = false;
|
||||
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|addr| format!("turn:{addr}"))
|
||||
.collect();
|
||||
let turn_server = IceServer {
|
||||
urls,
|
||||
username: Some("public".to_string()),
|
||||
credential: Some("o4tR7yG4rG2slhXqRUf9zgmHz".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&[stun_server, turn_server])?;
|
||||
Ok(json)
|
||||
vec![
|
||||
UnresolvedIceServer::Stun {
|
||||
hostname: "nine.testrun.org".to_string(),
|
||||
port: STUN_PORT,
|
||||
},
|
||||
UnresolvedIceServer::Turn {
|
||||
hostname: "turn.delta.chat".to_string(),
|
||||
port: STUN_PORT,
|
||||
username: "public".to_string(),
|
||||
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns JSON with ICE servers.
|
||||
@@ -717,7 +768,8 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
|
||||
/// <https://github.com/deltachat/deltachat-desktop/issues/5447>.
|
||||
pub async fn ice_servers(context: &Context) -> Result<String> {
|
||||
if let Some(ref metadata) = *context.metadata.read().await {
|
||||
Ok(metadata.ice_servers.clone())
|
||||
let ice_servers = resolve_ice_servers(context, metadata.ice_servers.clone()).await?;
|
||||
Ok(ice_servers)
|
||||
} else {
|
||||
Ok("[]".to_string())
|
||||
}
|
||||
|
||||
@@ -25,13 +25,6 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()>
|
||||
const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n";
|
||||
const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n";
|
||||
|
||||
/// Example from <https://datatracker.ietf.org/doc/rfc9143/>
|
||||
/// with `s= ` replaced with `s=-`.
|
||||
///
|
||||
/// `s=` cannot be empty according to RFC 3264,
|
||||
/// so it is more clear as `s=-`.
|
||||
const PLACE_INFO_VIDEO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP6 2001:db8::3\r\ns=-\r\nc=IN IP6 2001:db8::3\r\nt=0 0\r\na=group:BUNDLE foo bar\r\n\r\nm=audio 10000 RTP/AVP 0 8 97\r\nb=AS:200\r\na=mid:foo\r\na=rtcp-mux\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:97 iLBC/8000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\r\nm=video 10002 RTP/AVP 31 32\r\nb=AS:1000\r\na=mid:bar\r\na=rtcp-mux\r\na=rtpmap:31 H261/90000\r\na=rtpmap:32 MPV/90000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
|
||||
|
||||
async fn setup_call() -> Result<CallSetup> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
@@ -52,7 +45,7 @@ async fn setup_call() -> Result<CallSetup> {
|
||||
bob2.create_chat(&alice).await;
|
||||
|
||||
let test_msg_id = alice
|
||||
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string())
|
||||
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
|
||||
.await?;
|
||||
let sent1 = alice.pop_sent_msg().await;
|
||||
assert_eq!(sent1.sender_msg_id, test_msg_id);
|
||||
@@ -68,7 +61,8 @@ async fn setup_call() -> Result<CallSetup> {
|
||||
assert!(!info.is_incoming());
|
||||
assert!(!info.is_accepted());
|
||||
assert_eq!(info.place_call_info, PLACE_INFO);
|
||||
assert_text(t, m.id, "Outgoing call").await?;
|
||||
assert_eq!(info.has_video_initially(), true);
|
||||
assert_text(t, m.id, "Outgoing video call").await?;
|
||||
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
|
||||
}
|
||||
|
||||
@@ -89,7 +83,8 @@ async fn setup_call() -> Result<CallSetup> {
|
||||
assert!(info.is_incoming());
|
||||
assert!(!info.is_accepted());
|
||||
assert_eq!(info.place_call_info, PLACE_INFO);
|
||||
assert_text(t, m.id, "Incoming call").await?;
|
||||
assert_eq!(info.has_video_initially(), true);
|
||||
assert_text(t, m.id, "Incoming video call").await?;
|
||||
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
|
||||
}
|
||||
|
||||
@@ -120,7 +115,7 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
// Bob accepts the incoming call
|
||||
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
|
||||
.await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming call").await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming video call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.await;
|
||||
@@ -134,7 +129,7 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active);
|
||||
|
||||
bob2.recv_msg_trash(&sent2).await;
|
||||
assert_text(&bob, bob_call.id, "Incoming call").await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming video call").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.await;
|
||||
@@ -147,7 +142,7 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
|
||||
// Alice receives the acceptance message
|
||||
alice.recv_msg_trash(&sent2).await;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call").await?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing video call").await?;
|
||||
let ev = alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
|
||||
@@ -169,7 +164,7 @@ async fn accept_call() -> Result<CallSetup> {
|
||||
assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active);
|
||||
|
||||
alice2.recv_msg_trash(&sent2).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing video call").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
|
||||
@@ -208,7 +203,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
|
||||
// Bob has accepted the call and also ends it
|
||||
bob.end_call(bob_call.id).await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -219,7 +214,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -230,7 +225,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
|
||||
// Alice receives the ending message
|
||||
alice.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -241,7 +236,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -271,7 +266,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
|
||||
// Bob has accepted the call but Alice ends it
|
||||
alice.end_call(alice_call.id).await?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -283,7 +278,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
@@ -295,7 +290,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
|
||||
// Bob receives the ending message
|
||||
bob.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -305,7 +300,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
));
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
@@ -425,7 +420,7 @@ async fn test_caller_cancels_call() -> Result<()> {
|
||||
// Test that message summary says it is a missed call.
|
||||
let bob_call_msg = Message::load_from_db(&bob, bob_call.id).await?;
|
||||
let summary = bob_call_msg.get_summary(&bob, None).await?;
|
||||
assert_eq!(summary.text, "📞 Missed call");
|
||||
assert_eq!(summary.text, "🎥 Missed call");
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Missed call").await?;
|
||||
@@ -525,13 +520,6 @@ async fn test_update_call_text() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_sdp_has_video() {
|
||||
assert!(sdp_has_video("foobar").is_err());
|
||||
assert_eq!(sdp_has_video(PLACE_INFO).unwrap(), false);
|
||||
assert_eq!(sdp_has_video(PLACE_INFO_VIDEO).unwrap(), true);
|
||||
}
|
||||
|
||||
/// Tests that calls are forwarded as text messages.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_forward_call() -> Result<()> {
|
||||
@@ -542,7 +530,7 @@ async fn test_forward_call() -> Result<()> {
|
||||
|
||||
let alice_bob_chat = alice.create_chat(bob).await;
|
||||
let alice_msg_id = alice
|
||||
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string())
|
||||
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string(), true)
|
||||
.await
|
||||
.context("Failed to place a call")?;
|
||||
let alice_call = Message::load_from_db(alice, alice_msg_id).await?;
|
||||
|
||||
129
src/chat.rs
129
src/chat.rs
@@ -19,6 +19,7 @@ use strum_macros::EnumIter;
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::chatlist_events;
|
||||
use crate::color::str_to_color;
|
||||
use crate::config::Config;
|
||||
use crate::constants::{
|
||||
@@ -42,7 +43,7 @@ use crate::mimefactory::{MimeFactory, RenderedEmail};
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::receive_imf::ReceivedMsg;
|
||||
use crate::smtp::send_msg_to_smtp;
|
||||
use crate::smtp::{self, send_msg_to_smtp};
|
||||
use crate::stock_str;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::{
|
||||
@@ -51,7 +52,6 @@ use crate::tools::{
|
||||
gm2local_offset, normalize_text, smeared_time, time, truncate_msg_text,
|
||||
};
|
||||
use crate::webxdc::StatusUpdateSerial;
|
||||
use crate::{chatlist_events, imap};
|
||||
|
||||
pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3;
|
||||
|
||||
@@ -618,7 +618,6 @@ impl ChatId {
|
||||
);
|
||||
|
||||
let chat = Chat::load_from_db(context, self).await?;
|
||||
let delete_msgs_target = context.get_delete_msgs_target().await?;
|
||||
let sync_id = match sync {
|
||||
Nosync => None,
|
||||
Sync => chat.get_sync_id(context).await?,
|
||||
@@ -628,15 +627,11 @@ impl ChatId {
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
transaction.execute(
|
||||
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=? AND rfc724_mid!='')",
|
||||
(&delete_msgs_target, self,),
|
||||
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=? AND rfc724_mid!='')",
|
||||
(self,),
|
||||
)?;
|
||||
transaction.execute(
|
||||
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
|
||||
(&delete_msgs_target, self,),
|
||||
)?;
|
||||
transaction.execute(
|
||||
"DELETE FROM smtp WHERE msg_id IN (SELECT id FROM msgs WHERE chat_id=?)",
|
||||
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
|
||||
(self,),
|
||||
)?;
|
||||
transaction.execute(
|
||||
@@ -1156,7 +1151,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
return Ok(stock_str::encr_none(context).await);
|
||||
}
|
||||
|
||||
let mut ret = stock_str::e2e_available(context).await + "\n";
|
||||
let mut ret = stock_str::messages_e2e_encrypted(context).await + "\n";
|
||||
|
||||
for &contact_id in get_chat_contacts(context, self)
|
||||
.await?
|
||||
@@ -1890,11 +1885,7 @@ impl Chat {
|
||||
|
||||
let (msg_text, was_truncated) = truncate_msg_text(context, msg.text.clone()).await?;
|
||||
let new_mime_headers = if msg.has_html() {
|
||||
if msg.param.exists(Param::Forwarded) {
|
||||
msg.get_id().get_html(context).await?
|
||||
} else {
|
||||
msg.param.get(Param::SendHtml).map(|s| s.to_string())
|
||||
}
|
||||
msg.param.get(Param::SendHtml).map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -2135,10 +2126,11 @@ pub(crate) async fn sync(context: &Context, id: SyncId, action: SyncAction) -> R
|
||||
}
|
||||
|
||||
/// Whether the chat is pinned or archived.
|
||||
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter)]
|
||||
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter, Default)]
|
||||
#[repr(i8)]
|
||||
pub enum ChatVisibility {
|
||||
/// Chat is neither archived nor pinned.
|
||||
#[default]
|
||||
Normal = 0,
|
||||
|
||||
/// Chat is archived.
|
||||
@@ -2838,32 +2830,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
let from = context.get_primary_self_addr().await?;
|
||||
let lowercase_from = from.to_lowercase();
|
||||
|
||||
// Send BCC to self if it is enabled.
|
||||
//
|
||||
// Previous versions of Delta Chat did not send BCC self
|
||||
// if DeleteServerAfter was set to immediately delete messages
|
||||
// from the server. This is not the case anymore
|
||||
// because BCC-self messages are also used to detect
|
||||
// that message was sent if SMTP server is slow to respond
|
||||
// and connection is frequently lost
|
||||
// before receiving status line. NB: This is not a problem for chatmail servers, so `BccSelf`
|
||||
// disabled by default is fine.
|
||||
//
|
||||
// `from` must be the last addr, see `receive_imf_inner()` why.
|
||||
recipients.retain(|x| x.to_lowercase() != lowercase_from);
|
||||
if (context.get_config_bool(Config::BccSelf).await?
|
||||
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage)
|
||||
&& (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty())
|
||||
if context.get_config_bool(Config::BccSelf).await?
|
||||
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage
|
||||
{
|
||||
// Avoid sending unencrypted messages to all transports, chatmail relays won't accept
|
||||
// them. Normally the user should have a non-chatmail primary transport to send unencrypted
|
||||
// messages.
|
||||
if needs_encryption {
|
||||
for addr in context.get_secondary_self_addrs().await? {
|
||||
recipients.push(addr);
|
||||
}
|
||||
}
|
||||
recipients.push(from);
|
||||
smtp::add_self_recipients(context, &mut recipients, needs_encryption).await?;
|
||||
}
|
||||
|
||||
// Default Webxdc integrations are hidden messages and must not be sent out
|
||||
@@ -3230,6 +3201,36 @@ pub async fn get_chat_msgs_ex(
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Marks all unread messages in all chats as noticed.
|
||||
/// Ignores messages from blocked contacts, but does not ignore messages in muted chats.
|
||||
pub async fn marknoticed_all_chats(context: &Context) -> Result<()> {
|
||||
// The sql statement here is similar to the one in get_fresh_msgs
|
||||
let list = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
"SELECT DISTINCT(c.id)
|
||||
FROM msgs m
|
||||
INNER JOIN chats c
|
||||
ON m.chat_id=c.id
|
||||
WHERE m.state=?
|
||||
AND m.hidden=0
|
||||
AND m.chat_id>9
|
||||
AND c.blocked=0;",
|
||||
(MessageState::InFresh,),
|
||||
|row| {
|
||||
let msg_id: ChatId = row.get(0)?;
|
||||
Ok(msg_id)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
for chat_id in list {
|
||||
marknoticed_chat(context, chat_id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks all messages in the chat as noticed.
|
||||
/// If the given chat-id is the archive-link, marks all messages in all archived chats as noticed.
|
||||
pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()> {
|
||||
@@ -3291,7 +3292,7 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
|
||||
let hidden_messages = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
"SELECT id, rfc724_mid FROM msgs
|
||||
"SELECT id FROM msgs
|
||||
WHERE state=?
|
||||
AND hidden=1
|
||||
AND chat_id=?
|
||||
@@ -3299,16 +3300,11 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
|
||||
(MessageState::InFresh, chat_id), // No need to check for InNoticed messages, because reactions are never InNoticed
|
||||
|row| {
|
||||
let msg_id: MsgId = row.get(0)?;
|
||||
let rfc724_mid: String = row.get(1)?;
|
||||
Ok((msg_id, rfc724_mid))
|
||||
Ok(msg_id)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
for (msg_id, rfc724_mid) in &hidden_messages {
|
||||
message::update_msg_state(context, *msg_id, MessageState::InSeen).await?;
|
||||
imap::markseen_on_imap_table(context, rfc724_mid).await?;
|
||||
}
|
||||
|
||||
message::markseen_msgs(context, hidden_messages).await?;
|
||||
if noticed_msgs_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -3330,6 +3326,10 @@ pub(crate) async fn mark_old_messages_as_noticed(
|
||||
context: &Context,
|
||||
mut msgs: Vec<ReceivedMsg>,
|
||||
) -> Result<()> {
|
||||
if context.get_config_bool(Config::TeamProfile).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
msgs.retain(|m| m.state.is_outgoing());
|
||||
if msgs.is_empty() {
|
||||
return Ok(());
|
||||
@@ -4112,7 +4112,7 @@ pub async fn remove_contact_from_chat(
|
||||
} else {
|
||||
let mut sync = Nosync;
|
||||
|
||||
if chat.is_promoted() {
|
||||
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?;
|
||||
@@ -4362,20 +4362,30 @@ pub async fn forward_msgs_2ctx(
|
||||
msg.param = Params::new();
|
||||
|
||||
if msg.get_viewtype() != Viewtype::Sticker {
|
||||
let forwarded_msg_id = match ctx_src.blobdir == ctx_dst.blobdir {
|
||||
true => src_msg_id,
|
||||
false => MsgId::new_unset(),
|
||||
};
|
||||
msg.param
|
||||
.set_int(Param::Forwarded, src_msg_id.to_u32() as i32);
|
||||
.set_int(Param::Forwarded, forwarded_msg_id.to_u32() as i32);
|
||||
}
|
||||
|
||||
if msg.get_viewtype() == Viewtype::Call {
|
||||
msg.viewtype = Viewtype::Text;
|
||||
}
|
||||
|
||||
if msg.download_state != DownloadState::Done {
|
||||
msg.text += &msg.additional_text;
|
||||
}
|
||||
msg.text += &msg.additional_text;
|
||||
|
||||
let param = &mut param;
|
||||
msg.param.steal(param, Param::File);
|
||||
|
||||
// When forwarding between different accounts, blob files must be physically copied
|
||||
// because each account has its own blob directory.
|
||||
if ctx_src.blobdir == ctx_dst.blobdir {
|
||||
msg.param.steal(param, Param::File);
|
||||
} else if let Some(src_path) = param.get_file_path(ctx_src)? {
|
||||
let new_blob = BlobObject::create_and_deduplicate(ctx_dst, &src_path, &src_path)
|
||||
.context("Failed to copy blob file to destination account")?;
|
||||
msg.param.set(Param::File, new_blob.as_name());
|
||||
}
|
||||
msg.param.steal(param, Param::Filename);
|
||||
msg.param.steal(param, Param::Width);
|
||||
msg.param.steal(param, Param::Height);
|
||||
@@ -4384,6 +4394,9 @@ pub async fn forward_msgs_2ctx(
|
||||
msg.param.steal(param, Param::ProtectQuote);
|
||||
msg.param.steal(param, Param::Quote);
|
||||
msg.param.steal(param, Param::Summary1);
|
||||
if msg.has_html() {
|
||||
msg.set_html(src_msg_id.get_html(ctx_src).await?);
|
||||
}
|
||||
msg.in_reply_to = None;
|
||||
|
||||
// do not leak data as group names; a default subject is generated by mimefactory
|
||||
@@ -4453,9 +4466,7 @@ pub(crate) async fn save_copy_in_self_talk(
|
||||
msg.param.remove(Param::PostMessageFileBytes);
|
||||
msg.param.remove(Param::PostMessageViewtype);
|
||||
|
||||
if msg.download_state != DownloadState::Done {
|
||||
msg.text += &msg.additional_text;
|
||||
}
|
||||
msg.text += &msg.additional_text;
|
||||
|
||||
if !msg.original_msg_id.is_unset() {
|
||||
bail!("message already saved.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::Event;
|
||||
use crate::chatlist::get_archived_cnt;
|
||||
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
|
||||
use crate::ephemeral::Timer;
|
||||
@@ -1240,6 +1241,96 @@ async fn test_unarchive_if_muted() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_marknoticed_all_chats() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
tcm.section("alice: create chats & promote them by sending a message");
|
||||
|
||||
let alice_chat_normal = alice
|
||||
.create_group_with_members("Chat (normal)", &[alice, bob])
|
||||
.await;
|
||||
send_text_msg(alice, alice_chat_normal, "Hi".to_string()).await?;
|
||||
|
||||
let alice_chat_muted = alice
|
||||
.create_group_with_members("Chat (muted)", &[alice, bob])
|
||||
.await;
|
||||
send_text_msg(alice, alice_chat_muted, "Hi".to_string()).await?;
|
||||
set_muted(&alice.ctx, alice_chat_muted, MuteDuration::Forever).await?;
|
||||
|
||||
let alice_chat_archived_and_muted = alice
|
||||
.create_group_with_members("Chat (archived and muted)", &[alice, bob])
|
||||
.await;
|
||||
send_text_msg(alice, alice_chat_archived_and_muted, "Hi".to_string()).await?;
|
||||
set_muted(
|
||||
&alice.ctx,
|
||||
alice_chat_archived_and_muted,
|
||||
MuteDuration::Forever,
|
||||
)
|
||||
.await?;
|
||||
alice_chat_archived_and_muted
|
||||
.set_visibility(&alice.ctx, ChatVisibility::Archived)
|
||||
.await?;
|
||||
|
||||
tcm.section("bob: receive messages, accept all chats and send a reply to each messsage");
|
||||
|
||||
while let Some(sent_msg) = alice.pop_sent_msg_opt(Duration::default()).await {
|
||||
let bob_message = bob.recv_msg(&sent_msg).await;
|
||||
let bob_chat_id = bob_message.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
send_text_msg(bob, bob_chat_id, "reply".to_string()).await?;
|
||||
}
|
||||
|
||||
tcm.section("alice: receive replies from bob");
|
||||
while let Some(sent_msg) = bob.pop_sent_msg_opt(Duration::default()).await {
|
||||
alice.recv_msg(&sent_msg).await;
|
||||
}
|
||||
// ensure chats have unread messages
|
||||
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 1);
|
||||
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 1);
|
||||
assert_eq!(
|
||||
alice_chat_archived_and_muted
|
||||
.get_fresh_msg_cnt(alice)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
|
||||
tcm.section("alice: mark as read");
|
||||
alice.evtracker.clear_events();
|
||||
marknoticed_all_chats(alice).await?;
|
||||
tcm.section("alice: check that chats are no longer unread and that chatlist update events were received");
|
||||
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 0);
|
||||
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 0);
|
||||
assert_eq!(
|
||||
alice_chat_archived_and_muted
|
||||
.get_fresh_msg_cnt(alice)
|
||||
.await?,
|
||||
0
|
||||
);
|
||||
|
||||
let emitted_events = alice.evtracker.take_events();
|
||||
for event in &[
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(alice_chat_normal),
|
||||
},
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(alice_chat_muted),
|
||||
},
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(alice_chat_archived_and_muted),
|
||||
},
|
||||
EventType::ChatlistItemChanged {
|
||||
chat_id: Some(DC_CHAT_ID_ARCHIVED_LINK),
|
||||
},
|
||||
] {
|
||||
assert!(emitted_events.iter().any(|Event { typ, .. }| typ == event));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_archive_fresh_msgs() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -3344,6 +3435,11 @@ async fn test_remove_member_from_broadcast() -> Result<()> {
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
remove_contact_from_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
// Alice must not remember old members,
|
||||
// because we would like to remember the minimum information possible
|
||||
let past_contacts = get_past_chat_contacts(alice, alice_chat_id).await?;
|
||||
assert_eq!(past_contacts.len(), 0);
|
||||
|
||||
let remove_msg = alice.pop_sent_msg().await;
|
||||
let rcvd = bob.recv_msg(&remove_msg).await;
|
||||
assert_eq!(rcvd.text, "Member Me removed by alice@example.org.");
|
||||
@@ -3720,13 +3816,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
let chat_id = create_group(alice, "Group").await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available"
|
||||
"Messages are end-to-end encrypted."
|
||||
);
|
||||
|
||||
add_contact_to_chat(alice, chat_id, contact_bob).await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available\n\
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
bob@example.net\n\
|
||||
CCCB 5AA9 F6E1 141C 9431\n\
|
||||
@@ -3736,7 +3832,7 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
add_contact_to_chat(alice, chat_id, contact_fiona).await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available\n\
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
fiona@example.net\n\
|
||||
C8BA 50BF 4AC1 2FAF 38D7\n\
|
||||
@@ -3750,13 +3846,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
let email_chat = alice.create_email_chat(bob).await;
|
||||
assert_eq!(
|
||||
email_chat.id.get_encryption_info(alice).await?,
|
||||
"No encryption"
|
||||
"No encryption."
|
||||
);
|
||||
|
||||
alice.sql.execute("DELETE FROM public_keys", ()).await?;
|
||||
assert_eq!(
|
||||
chat_id.get_encryption_info(alice).await?,
|
||||
"End-to-end encryption available\n\
|
||||
"Messages are end-to-end encrypted.\n\
|
||||
\n\
|
||||
fiona@example.net\n\
|
||||
(key missing)\n\
|
||||
@@ -5395,6 +5491,97 @@ async fn test_forward_msgs_2ctx() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_forward_msgs_2ctx_with_file() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
// First, establish a chat between Alice and Bob to have the chat IDs
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
|
||||
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
|
||||
let bob_chat_id = bob_alice_msg.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
|
||||
// Alice sends a message with an attached file to her self-chat
|
||||
let alice_self_chat = alice.get_self_chat().await;
|
||||
let file_bytes = b"test file content";
|
||||
let file = alice.get_blobdir().join("test.txt");
|
||||
tokio::fs::write(&file, file_bytes).await?;
|
||||
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
|
||||
msg.set_text("Here's a file".to_string());
|
||||
|
||||
alice.send_msg(alice_self_chat.id, &mut msg).await;
|
||||
let alice_self_msg = alice.get_last_msg().await;
|
||||
|
||||
// Verify the file exists in Alice's blobdir
|
||||
assert_eq!(alice_self_msg.viewtype, Viewtype::File);
|
||||
let alice_original_file_path = alice_self_msg.get_file(alice).unwrap();
|
||||
let alice_original_content = tokio::fs::read(&alice_original_file_path).await?;
|
||||
assert_eq!(alice_original_content, file_bytes);
|
||||
|
||||
// Alice forwards the message to Bob using forward_msgs_2ctx
|
||||
forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await?;
|
||||
|
||||
// Bob should have the forwarded message with the file in his database
|
||||
let bob_msg = bob.get_last_msg().await;
|
||||
assert_eq!(bob_msg.viewtype, Viewtype::File);
|
||||
assert!(bob_msg.is_forwarded());
|
||||
assert_eq!(bob_msg.text, "Here's a file");
|
||||
assert_eq!(bob_msg.from_id, ContactId::SELF);
|
||||
|
||||
// Verify Bob has the file in his blobdir with correct content
|
||||
let bob_file_path = bob_msg.get_file(bob).unwrap();
|
||||
let bob_file_content = tokio::fs::read(&bob_file_path).await?;
|
||||
assert_eq!(bob_file_content, file_bytes);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_forward_msgs_2ctx_missing_blob() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
|
||||
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
|
||||
let bob_chat_id = bob_alice_msg.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
// Alice sends a file to her self-chat
|
||||
let alice_self_chat = alice.get_self_chat().await;
|
||||
let file_bytes = b"test content";
|
||||
let file = alice.get_blobdir().join("test.txt");
|
||||
tokio::fs::write(&file, file_bytes).await?;
|
||||
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
|
||||
msg.set_text("File message".to_string());
|
||||
|
||||
alice.send_msg(alice_self_chat.id, &mut msg).await;
|
||||
let alice_self_msg = alice.get_last_msg().await;
|
||||
|
||||
// Delete the blob file from Alice's blobdir to simulate a missing file
|
||||
let alice_file_path = alice_self_msg.get_file(alice).unwrap();
|
||||
tokio::fs::remove_file(&alice_file_path).await?;
|
||||
|
||||
// Alice tries to forward the message - this should fail with an error
|
||||
let result = forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Failed to copy blob file")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that in multi-device setup
|
||||
/// second device learns the key of a contact
|
||||
/// via Autocrypt-Gossip in 1:1 chats.
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Chatlist {
|
||||
/// the pseudo-chat DC_CHAT_ID_ARCHIVED_LINK is added if there are *any* archived
|
||||
/// chats
|
||||
/// - the flag DC_GCL_FOR_FORWARDING sorts "Saved messages" to the top of the chatlist
|
||||
/// and hides the device-chat and contact requests
|
||||
/// and hides the device-chat, contact requests and incoming broadcasts.
|
||||
/// typically used on forwarding, may be combined with DC_GCL_NO_SPECIALS
|
||||
/// - if the flag DC_GCL_NO_SPECIALS is set, archive link is not added
|
||||
/// to the list (may be used eg. for selecting chats on forwarding, the flag is
|
||||
@@ -224,8 +224,9 @@ impl Chatlist {
|
||||
let process_rows = |rows: rusqlite::AndThenRows<_>| {
|
||||
rows.filter_map(|row: std::result::Result<(_, _, Params, _), _>| match row {
|
||||
Ok((chat_id, typ, param, msg_id)) => {
|
||||
if typ == Chattype::Mailinglist
|
||||
&& param.get(Param::ListPost).is_none_or_empty()
|
||||
if typ == Chattype::InBroadcast
|
||||
|| (typ == Chattype::Mailinglist
|
||||
&& param.get(Param::ListPost).is_none_or_empty())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
@@ -597,6 +598,41 @@ mod tests {
|
||||
assert_eq!(chats.len(), 1);
|
||||
}
|
||||
|
||||
/// Test that DC_CHAT_TYPE_IN_BROADCAST are hidden
|
||||
/// and DC_CHAT_TYPE_OUT_BROADCAST are shown in chatlist for forwarding.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_visiblity_on_forward() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_broadcast_a_id = create_broadcast(alice, "Channel Alice".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_broadcast_a_id))
|
||||
.await
|
||||
.unwrap();
|
||||
let bob_broadcast_a_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
let bob_broadcast_b_id = create_broadcast(bob, "Channel Bob".to_string()).await?;
|
||||
|
||||
let chats = Chatlist::try_load(bob, DC_GCL_FOR_FORWARDING, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!chats
|
||||
.iter()
|
||||
.any(|(chat_id, _)| chat_id == &bob_broadcast_a_id),
|
||||
"alice broadcast is not shown in bobs forwarding chatlist"
|
||||
);
|
||||
assert!(
|
||||
chats
|
||||
.iter()
|
||||
.any(|(chat_id, _)| chat_id == &bob_broadcast_b_id),
|
||||
"bobs own broadcast is shown in his forwarding chatlist"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_search_special_chat_names() {
|
||||
let t = TestContext::new_alice().await;
|
||||
|
||||
@@ -175,11 +175,6 @@ pub enum Config {
|
||||
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
|
||||
MediaQuality,
|
||||
|
||||
/// If set to "1", then existing messages are considered to be already fetched.
|
||||
/// This flag is reset after successful configuration.
|
||||
#[strum(props(default = "1"))]
|
||||
FetchedExistingMsgs,
|
||||
|
||||
/// Timer in seconds after which the message is deleted from the
|
||||
/// server.
|
||||
///
|
||||
@@ -199,10 +194,6 @@ pub enum Config {
|
||||
#[strum(props(default = "0"))]
|
||||
DeleteDeviceAfter,
|
||||
|
||||
/// Move messages to the Trash folder instead of marking them "\Deleted". Overrides
|
||||
/// `ProviderOptions::delete_to_trash`.
|
||||
DeleteToTrash,
|
||||
|
||||
/// The primary email address.
|
||||
ConfiguredAddr,
|
||||
|
||||
@@ -280,9 +271,6 @@ pub enum Config {
|
||||
/// Configured folder for chat messages.
|
||||
ConfiguredMvboxFolder,
|
||||
|
||||
/// Configured "Trash" folder.
|
||||
ConfiguredTrashFolder,
|
||||
|
||||
/// Unix timestamp of the last successful configuration.
|
||||
ConfiguredTimestamp,
|
||||
|
||||
@@ -340,10 +328,6 @@ pub enum Config {
|
||||
/// Timestamp of the last `CantDecryptOutgoingMsgs` notification.
|
||||
LastCantDecryptOutgoingMsgs,
|
||||
|
||||
/// To how many seconds to debounce scan_all_folders. Used mainly in tests, to disable debouncing completely.
|
||||
#[strum(props(default = "60"))]
|
||||
ScanAllFoldersDebounceSecs,
|
||||
|
||||
/// Whether to avoid using IMAP IDLE even if the server supports it.
|
||||
///
|
||||
/// This is a developer option for testing "fake idle".
|
||||
@@ -701,7 +685,6 @@ impl Context {
|
||||
| Config::MdnsEnabled
|
||||
| Config::MvboxMove
|
||||
| Config::OnlyFetchMvbox
|
||||
| Config::DeleteToTrash
|
||||
| Config::Configured
|
||||
| Config::Bot
|
||||
| Config::NotifyAboutWrongPw
|
||||
@@ -725,12 +708,7 @@ impl Context {
|
||||
Self::check_config(key, value)?;
|
||||
|
||||
let n_transports = self.count_transports().await?;
|
||||
if n_transports > 1
|
||||
&& matches!(
|
||||
key,
|
||||
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
|
||||
)
|
||||
{
|
||||
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
|
||||
bail!("Cannot reconfigure {key} when multiple transports are configured");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ use percent_encoding::utf8_percent_encode;
|
||||
use server_params::{ServerParams, expand_param_vector};
|
||||
use tokio::task;
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use crate::config::Config;
|
||||
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
|
||||
use crate::context::Context;
|
||||
use crate::imap::Imap;
|
||||
@@ -286,11 +286,6 @@ impl Context {
|
||||
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
|
||||
);
|
||||
}
|
||||
if self.get_config(Config::ShowEmails).await?.as_deref() != Some("2") {
|
||||
bail!(
|
||||
"To use additional relays, set the legacy option \"Settings / Advanced / Show Classic Emails\" to \"All\"."
|
||||
);
|
||||
}
|
||||
|
||||
if self
|
||||
.sql
|
||||
@@ -631,8 +626,6 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
|
||||
progress!(ctx, 920);
|
||||
|
||||
ctx.set_config_internal(Config::FetchedExistingMsgs, config::from_bool(false))
|
||||
.await?;
|
||||
ctx.scheduler.interrupt_inbox().await;
|
||||
|
||||
progress!(ctx, 940);
|
||||
|
||||
@@ -1299,18 +1299,6 @@ WHERE addr=?
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns number of blocked contacts.
|
||||
pub async fn get_blocked_cnt(context: &Context) -> Result<usize> {
|
||||
let count = context
|
||||
.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM contacts WHERE id>? AND blocked!=0",
|
||||
(ContactId::LAST_SPECIAL,),
|
||||
)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Get blocked contacts.
|
||||
pub async fn get_all_blocked(context: &Context) -> Result<Vec<ContactId>> {
|
||||
Contact::update_blocked_mailinglist_contacts(context)
|
||||
@@ -1354,13 +1342,13 @@ WHERE addr=?
|
||||
let fingerprint_other = fingerprint_other.to_string();
|
||||
|
||||
let stock_message = if contact.public_key(context).await?.is_some() {
|
||||
stock_str::e2e_available(context).await
|
||||
stock_str::messages_e2e_encrypted(context).await
|
||||
} else {
|
||||
stock_str::encr_none(context).await
|
||||
};
|
||||
|
||||
let finger_prints = stock_str::finger_prints(context).await;
|
||||
let mut ret = format!("{stock_message}.\n{finger_prints}:");
|
||||
let mut ret = format!("{stock_message}\n{finger_prints}:");
|
||||
|
||||
let fingerprint_self = load_self_public_key(context)
|
||||
.await?
|
||||
@@ -1466,7 +1454,7 @@ WHERE addr=?
|
||||
/// Returns true if the contact is a key-contact.
|
||||
/// Otherwise it is an addresss-contact.
|
||||
pub fn is_key_contact(&self) -> bool {
|
||||
self.fingerprint.is_some()
|
||||
self.fingerprint.is_some() || self.id == ContactId::SELF
|
||||
}
|
||||
|
||||
/// Returns OpenPGP fingerprint of a contact.
|
||||
|
||||
@@ -823,7 +823,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
|
||||
|
||||
let address_contact_bob_id = alice.add_or_lookup_address_contact_id(bob).await;
|
||||
let encrinfo = Contact::get_encrinfo(alice, address_contact_bob_id).await?;
|
||||
assert_eq!(encrinfo, "No encryption");
|
||||
assert_eq!(encrinfo, "No encryption.");
|
||||
|
||||
let contact = Contact::get_by_id(alice, address_contact_bob_id).await?;
|
||||
assert!(!contact.e2ee_avail(alice).await?);
|
||||
@@ -832,7 +832,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
|
||||
let encrinfo = Contact::get_encrinfo(alice, contact_bob_id).await?;
|
||||
assert_eq!(
|
||||
encrinfo,
|
||||
"End-to-end encryption available.
|
||||
"Messages are end-to-end encrypted.
|
||||
Fingerprints:
|
||||
|
||||
Me (alice@example.org):
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use async_channel::{self as channel, Receiver, Sender};
|
||||
use ratelimit::Ratelimit;
|
||||
use tokio::sync::{Mutex, Notify, RwLock};
|
||||
@@ -36,6 +36,8 @@ use crate::tools::{self, duration_to_str, time, time_elapsed};
|
||||
use crate::transport::ConfiguredLoginParam;
|
||||
use crate::{chatlist_events, stats};
|
||||
|
||||
pub use crate::scheduler::connectivity::Connectivity;
|
||||
|
||||
/// Builder for the [`Context`].
|
||||
///
|
||||
/// Many arguments to the [`Context`] are kind of optional and only needed to handle
|
||||
@@ -878,10 +880,6 @@ impl Context {
|
||||
.get_config(Config::ConfiguredMvboxFolder)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string());
|
||||
let configured_trash_folder = self
|
||||
.get_config(Config::ConfiguredTrashFolder)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string());
|
||||
|
||||
let mut res = get_info();
|
||||
|
||||
@@ -944,12 +942,6 @@ impl Context {
|
||||
}
|
||||
|
||||
res.insert("secondary_addrs", secondary_addrs);
|
||||
res.insert(
|
||||
"fetched_existing_msgs",
|
||||
self.get_config_bool(Config::FetchedExistingMsgs)
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"show_emails",
|
||||
self.get_config_int(Config::ShowEmails).await?.to_string(),
|
||||
@@ -972,7 +964,6 @@ impl Context {
|
||||
);
|
||||
res.insert("configured_inbox_folder", configured_inbox_folder);
|
||||
res.insert("configured_mvbox_folder", configured_mvbox_folder);
|
||||
res.insert("configured_trash_folder", configured_trash_folder);
|
||||
res.insert("mdns_enabled", mdns_enabled.to_string());
|
||||
res.insert("bcc_self", bcc_self.to_string());
|
||||
res.insert("sync_msgs", sync_msgs.to_string());
|
||||
@@ -996,12 +987,6 @@ impl Context {
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"delete_to_trash",
|
||||
self.get_config(Config::DeleteToTrash)
|
||||
.await?
|
||||
.unwrap_or_else(|| "<unset>".to_string()),
|
||||
);
|
||||
res.insert(
|
||||
"last_housekeeping",
|
||||
self.get_config_int(Config::LastHousekeeping)
|
||||
@@ -1014,12 +999,6 @@ impl Context {
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"scan_all_folders_debounce_secs",
|
||||
self.get_config_int(Config::ScanAllFoldersDebounceSecs)
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"quota_exceeding",
|
||||
self.get_config_int(Config::QuotaExceeding)
|
||||
@@ -1120,21 +1099,19 @@ impl Context {
|
||||
let list = self
|
||||
.sql
|
||||
.query_map_vec(
|
||||
concat!(
|
||||
"SELECT m.id",
|
||||
" FROM msgs m",
|
||||
" LEFT JOIN contacts ct",
|
||||
" ON m.from_id=ct.id",
|
||||
" LEFT JOIN chats c",
|
||||
" ON m.chat_id=c.id",
|
||||
" WHERE m.state=?",
|
||||
" AND m.hidden=0",
|
||||
" AND m.chat_id>9",
|
||||
" AND ct.blocked=0",
|
||||
" AND c.blocked=0",
|
||||
" AND NOT(c.muted_until=-1 OR c.muted_until>?)",
|
||||
" ORDER BY m.timestamp DESC,m.id DESC;"
|
||||
),
|
||||
"SELECT m.id
|
||||
FROM msgs m
|
||||
LEFT JOIN contacts ct
|
||||
ON m.from_id=ct.id
|
||||
LEFT JOIN chats c
|
||||
ON m.chat_id=c.id
|
||||
WHERE m.state=?
|
||||
AND m.hidden=0
|
||||
AND m.chat_id>9
|
||||
AND ct.blocked=0
|
||||
AND c.blocked=0
|
||||
AND NOT(c.muted_until=-1 OR c.muted_until>?)
|
||||
ORDER BY m.timestamp DESC,m.id DESC",
|
||||
(MessageState::InFresh, time()),
|
||||
|row| {
|
||||
let msg_id: MsgId = row.get(0)?;
|
||||
@@ -1286,45 +1263,12 @@ impl Context {
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Returns true if given folder name is the name of the inbox.
|
||||
pub async fn is_inbox(&self, folder_name: &str) -> Result<bool> {
|
||||
let inbox = self.get_config(Config::ConfiguredInboxFolder).await?;
|
||||
Ok(inbox.as_deref() == Some(folder_name))
|
||||
}
|
||||
|
||||
/// Returns true if given folder name is the name of the "DeltaChat" folder.
|
||||
pub async fn is_mvbox(&self, folder_name: &str) -> Result<bool> {
|
||||
let mvbox = self.get_config(Config::ConfiguredMvboxFolder).await?;
|
||||
Ok(mvbox.as_deref() == Some(folder_name))
|
||||
}
|
||||
|
||||
/// Returns true if given folder name is the name of the trash folder.
|
||||
pub async fn is_trash(&self, folder_name: &str) -> Result<bool> {
|
||||
let trash = self.get_config(Config::ConfiguredTrashFolder).await?;
|
||||
Ok(trash.as_deref() == Some(folder_name))
|
||||
}
|
||||
|
||||
pub(crate) async fn should_delete_to_trash(&self) -> Result<bool> {
|
||||
if let Some(v) = self.get_config_bool_opt(Config::DeleteToTrash).await? {
|
||||
return Ok(v);
|
||||
}
|
||||
if let Some(provider) = self.get_configured_provider().await? {
|
||||
return Ok(provider.opt.delete_to_trash);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Returns `target` for deleted messages as per `imap` table. Empty string means "delete w/o
|
||||
/// moving to trash".
|
||||
pub(crate) async fn get_delete_msgs_target(&self) -> Result<String> {
|
||||
if !self.should_delete_to_trash().await? {
|
||||
return Ok("".into());
|
||||
}
|
||||
self.get_config(Config::ConfiguredTrashFolder)
|
||||
.await?
|
||||
.context("No configured trash folder")
|
||||
}
|
||||
|
||||
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
|
||||
let mut blob_fname = OsString::new();
|
||||
blob_fname.push(dbfile.file_name().unwrap_or_default());
|
||||
|
||||
@@ -99,7 +99,8 @@ impl MsgId {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore.
|
||||
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore or has
|
||||
/// the download state up to date.
|
||||
pub(crate) async fn update_download_state(
|
||||
self,
|
||||
context: &Context,
|
||||
@@ -108,7 +109,7 @@ impl MsgId {
|
||||
if context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE msgs SET download_state=? WHERE id=?;",
|
||||
"UPDATE msgs SET download_state=? WHERE id=? AND download_state<>?1",
|
||||
(download_state, self),
|
||||
)
|
||||
.await?
|
||||
@@ -135,42 +136,46 @@ impl Message {
|
||||
}
|
||||
}
|
||||
|
||||
/// Actually download a message partially downloaded before.
|
||||
/// Actually downloads a message partially downloaded before if the message is available on the
|
||||
/// session transport, in which case returns `Some`. If the message is available on another
|
||||
/// transport, returns `None`.
|
||||
///
|
||||
/// Most messages are downloaded automatically on fetch instead.
|
||||
pub(crate) async fn download_msg(
|
||||
context: &Context,
|
||||
rfc724_mid: String,
|
||||
session: &mut Session,
|
||||
) -> Result<()> {
|
||||
) -> Result<Option<()>> {
|
||||
let transport_id = session.transport_id();
|
||||
let row = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT uid, folder FROM imap
|
||||
WHERE rfc724_mid=?
|
||||
AND transport_id=?
|
||||
AND target!=''",
|
||||
"SELECT uid, folder, transport_id FROM imap
|
||||
WHERE rfc724_mid=? AND target!=''
|
||||
ORDER BY transport_id=? DESC LIMIT 1",
|
||||
(&rfc724_mid, transport_id),
|
||||
|row| {
|
||||
let server_uid: u32 = row.get(0)?;
|
||||
let server_folder: String = row.get(1)?;
|
||||
Ok((server_uid, server_folder))
|
||||
let msg_transport_id: u32 = row.get(2)?;
|
||||
Ok((server_uid, server_folder, msg_transport_id))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some((server_uid, server_folder)) = row else {
|
||||
let Some((server_uid, server_folder, msg_transport_id)) = row else {
|
||||
// No IMAP record found, we don't know the UID and folder.
|
||||
return Err(anyhow!(
|
||||
"IMAP location for {rfc724_mid:?} post-message is unknown"
|
||||
));
|
||||
};
|
||||
|
||||
if msg_transport_id != transport_id {
|
||||
return Ok(None);
|
||||
}
|
||||
session
|
||||
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(Some(()))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -189,10 +194,7 @@ impl Session {
|
||||
bail!("Attempt to fetch UID 0");
|
||||
}
|
||||
|
||||
let create = false;
|
||||
let folder_exists = self
|
||||
.select_with_uidvalidity(context, folder, create)
|
||||
.await?;
|
||||
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
|
||||
ensure!(folder_exists, "No folder {folder}");
|
||||
|
||||
// we are connected, and the folder is selected
|
||||
@@ -275,7 +277,7 @@ pub(crate) async fn download_msgs(context: &Context, session: &mut Session) -> R
|
||||
|
||||
for rfc724_mid in &rfc724_mids {
|
||||
let res = download_msg(context, rfc724_mid.clone(), session).await;
|
||||
if res.is_ok() {
|
||||
if let Ok(Some(())) = res {
|
||||
delete_from_downloads(context, rfc724_mid).await?;
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
}
|
||||
@@ -330,7 +332,7 @@ pub(crate) async fn download_known_post_messages_without_pre_message(
|
||||
// The message may be in the wrong order,
|
||||
// but at least we have it at all.
|
||||
let res = download_msg(context, rfc724_mid.clone(), session).await;
|
||||
if res.is_ok() {
|
||||
if let Ok(Some(())) = res {
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
}
|
||||
if let Err(err) = res {
|
||||
|
||||
@@ -665,25 +665,19 @@ pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()
|
||||
now - max(delete_server_after, MIN_DELETE_SERVER_AFTER),
|
||||
),
|
||||
};
|
||||
let target = context.get_delete_msgs_target().await?;
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE imap
|
||||
SET target=?
|
||||
SET target=''
|
||||
WHERE rfc724_mid IN (
|
||||
SELECT rfc724_mid FROM msgs
|
||||
WHERE ((download_state = 0 AND timestamp < ?) OR
|
||||
(download_state != 0 AND timestamp < ?) OR
|
||||
(ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?))
|
||||
)",
|
||||
(
|
||||
&target,
|
||||
threshold_timestamp,
|
||||
threshold_timestamp_extended,
|
||||
now,
|
||||
),
|
||||
(threshold_timestamp, threshold_timestamp_extended, now),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ pub enum HeaderDef {
|
||||
ChatDispositionNotificationTo,
|
||||
ChatWebrtcRoom,
|
||||
ChatWebrtcAccepted,
|
||||
ChatWebrtcHasVideoInitially,
|
||||
|
||||
/// This message deletes the messages listed in the value by rfc724_mid.
|
||||
ChatDelete,
|
||||
|
||||
85
src/html.rs
85
src/html.rs
@@ -254,13 +254,20 @@ fn mimepart_to_data_url(mail: &mailparse::ParsedMail<'_>) -> Result<String> {
|
||||
|
||||
impl MsgId {
|
||||
/// Get HTML by database message id.
|
||||
/// This requires `mime_headers` field to be set for the message;
|
||||
/// this is the case at least when `Message.has_html()` returns true
|
||||
/// (we do not save raw mime unconditionally in the database to save space).
|
||||
/// Returns `Some` at least if `Message.has_html()` is true.
|
||||
/// NB: we do not save raw mime unconditionally in the database to save space.
|
||||
/// The corresponding ffi-function is `dc_get_msg_html()`.
|
||||
pub async fn get_html(self, context: &Context) -> Result<Option<String>> {
|
||||
let rawmime = message::get_mime_headers(context, self).await?;
|
||||
// If there are many concurrent db readers, going to the queue earlier makes sense.
|
||||
let (param, rawmime) = tokio::join!(
|
||||
self.get_param(context),
|
||||
message::get_mime_headers(context, self)
|
||||
);
|
||||
if let Some(html) = param?.get(SendHtml) {
|
||||
return Ok(Some(html.to_string()));
|
||||
}
|
||||
|
||||
let rawmime = rawmime?;
|
||||
if !rawmime.is_empty() {
|
||||
match HtmlMsgParser::from_bytes(context, &rawmime).await {
|
||||
Err(err) => {
|
||||
@@ -279,9 +286,9 @@ impl MsgId {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat;
|
||||
use crate::chat::{forward_msgs, save_msgs};
|
||||
use crate::chat::{self, Chat, forward_msgs, save_msgs};
|
||||
use crate::config::Config;
|
||||
use crate::constants;
|
||||
use crate::contact::ContactId;
|
||||
use crate::message::{MessengerMessage, Viewtype};
|
||||
use crate::receive_imf::receive_imf;
|
||||
@@ -440,7 +447,7 @@ test some special html-characters as < > and & but also " and &#x
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_html_forwarding() {
|
||||
async fn test_html_forwarding() -> Result<()> {
|
||||
// alice receives a non-delta html-message
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
@@ -459,31 +466,57 @@ test some special html-characters as < > and & but also " and &#x
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
|
||||
// alice: create chat with bob and forward received html-message there
|
||||
let chat = alice.create_chat_with_contact("", "bob@example.net").await;
|
||||
forward_msgs(alice, &[msg.get_id()], chat.get_id())
|
||||
let chat_alice = alice.create_chat_with_contact("", "bob@example.net").await;
|
||||
forward_msgs(alice, &[msg.get_id()], chat_alice.get_id())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = alice.get_last_msg_in(chat.get_id()).await;
|
||||
assert_eq!(msg.get_from_id(), ContactId::SELF);
|
||||
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
|
||||
assert!(msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
async fn check_sender(ctx: &TestContext, chat: &Chat) {
|
||||
let msg = ctx.get_last_msg_in(chat.get_id()).await;
|
||||
assert_eq!(msg.get_from_id(), ContactId::SELF);
|
||||
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
|
||||
assert!(msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(ctx).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
}
|
||||
check_sender(alice, &chat_alice).await;
|
||||
|
||||
// bob: check that bob also got the html-part of the forwarded message
|
||||
let bob = &tcm.bob().await;
|
||||
let chat = bob.create_chat_with_contact("", "alice@example.org").await;
|
||||
let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert_eq!(chat.id, msg.chat_id);
|
||||
assert_ne!(msg.get_from_id(), ContactId::SELF);
|
||||
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
|
||||
assert!(msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
let chat_bob = bob.create_chat_with_contact("", "alice@example.org").await;
|
||||
async fn check_receiver(ctx: &TestContext, chat: &Chat, sender: &TestContext) {
|
||||
let msg = ctx.recv_msg(&sender.pop_sent_msg().await).await;
|
||||
assert_eq!(chat.id, msg.chat_id);
|
||||
assert_ne!(msg.get_from_id(), ContactId::SELF);
|
||||
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
|
||||
assert!(msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(ctx).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
}
|
||||
check_receiver(bob, &chat_bob, alice).await;
|
||||
|
||||
// Let's say that the alice and bob profiles are on the same device,
|
||||
// so alice can forward the message to herself via bob profile!
|
||||
chat::forward_msgs_2ctx(alice, &[msg.get_id()], bob, chat_bob.get_id()).await?;
|
||||
check_sender(bob, &chat_bob).await;
|
||||
check_receiver(alice, &chat_alice, bob).await;
|
||||
|
||||
// Check cross-profile forwarding of long outgoing messages.
|
||||
let line = "this text with 42 chars is just repeated.\n";
|
||||
let long_txt = line.repeat(constants::DC_DESIRED_TEXT_LEN / line.len() + 2);
|
||||
let mut msg = Message::new_text(long_txt);
|
||||
alice.send_msg(chat_alice.id, &mut msg).await;
|
||||
let msg = alice.get_last_msg_in(chat_alice.id).await;
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(bob).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
let html = msg.id.get_html(alice).await?.unwrap();
|
||||
chat::forward_msgs_2ctx(alice, &[msg.get_id()], bob, chat_bob.get_id()).await?;
|
||||
let msg = bob.get_last_msg_in(chat_bob.id).await;
|
||||
assert!(msg.has_html());
|
||||
assert_eq!(msg.id.get_html(bob).await?.unwrap(), html);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
271
src/imap.rs
271
src/imap.rs
@@ -16,19 +16,20 @@ use std::{
|
||||
use anyhow::{Context as _, Result, bail, ensure, format_err};
|
||||
use async_channel::{self, Receiver, Sender};
|
||||
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
|
||||
use deltachat_contact_tools::ContactAddress;
|
||||
use futures::{FutureExt as _, TryStreamExt};
|
||||
use futures_lite::FutureExt;
|
||||
use num_traits::FromPrimitive;
|
||||
use ratelimit::Ratelimit;
|
||||
use url::Url;
|
||||
|
||||
use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata};
|
||||
use crate::calls::{
|
||||
UnresolvedIceServer, create_fallback_ice_servers, create_ice_servers_from_metadata,
|
||||
};
|
||||
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
|
||||
use crate::chatlist_events;
|
||||
use crate::config::Config;
|
||||
use crate::constants::{self, Blocked, DC_VERSION_STR, ShowEmails};
|
||||
use crate::contact::{Contact, ContactId, Modifier, Origin};
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
@@ -52,12 +53,10 @@ use crate::transport::{
|
||||
pub(crate) mod capabilities;
|
||||
mod client;
|
||||
mod idle;
|
||||
pub mod scan_folders;
|
||||
pub mod select_folder;
|
||||
pub(crate) mod session;
|
||||
|
||||
use client::{Client, determine_capabilities};
|
||||
use mailparse::SingleInfo;
|
||||
use session::Session;
|
||||
|
||||
pub(crate) const GENERATED_PREFIX: &str = "GEN_";
|
||||
@@ -134,16 +133,15 @@ pub(crate) struct ServerMetadata {
|
||||
|
||||
pub iroh_relay: Option<Url>,
|
||||
|
||||
/// JSON with ICE servers for WebRTC calls
|
||||
/// and the expiration timestamp.
|
||||
///
|
||||
/// If JSON is about to expire, new TURN credentials
|
||||
/// should be fetched from the server
|
||||
/// to be ready for WebRTC calls.
|
||||
pub ice_servers: String,
|
||||
/// ICE servers for WebRTC calls.
|
||||
pub ice_servers: Vec<UnresolvedIceServer>,
|
||||
|
||||
/// Timestamp when ICE servers are considered
|
||||
/// expired and should be updated.
|
||||
///
|
||||
/// If ICE servers are about to expire, new TURN credentials
|
||||
/// should be fetched from the server
|
||||
/// to be ready for WebRTC calls.
|
||||
pub ice_servers_expiration_timestamp: i64,
|
||||
}
|
||||
|
||||
@@ -184,7 +182,7 @@ impl FolderMeaning {
|
||||
FolderMeaning::Spam => None,
|
||||
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
|
||||
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
|
||||
FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder),
|
||||
FolderMeaning::Trash => None,
|
||||
FolderMeaning::Virtual => None,
|
||||
}
|
||||
}
|
||||
@@ -500,13 +498,7 @@ impl Imap {
|
||||
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
|
||||
.await?;
|
||||
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
|
||||
let is_chatmail = match context.get_config_bool(Config::FixIsChatmail).await? {
|
||||
false => session.is_chatmail(),
|
||||
true => context.get_config_bool(Config::IsChatmail).await?,
|
||||
};
|
||||
let create_mvbox = !is_chatmail || context.get_config_bool(Config::MvboxMove).await?;
|
||||
self.configure_folders(context, &mut session, create_mvbox)
|
||||
.await?;
|
||||
self.configure_folders(context, &mut session).await?;
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
@@ -564,9 +556,8 @@ impl Imap {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let create = false;
|
||||
let folder_exists = session
|
||||
.select_with_uidvalidity(context, folder, create)
|
||||
.select_with_uidvalidity(context, folder)
|
||||
.await
|
||||
.with_context(|| format!("Failed to select folder {folder:?}"))?;
|
||||
if !folder_exists {
|
||||
@@ -619,7 +610,6 @@ impl Imap {
|
||||
let mut download_later: Vec<String> = Vec::new();
|
||||
let mut uid_message_ids = BTreeMap::new();
|
||||
let mut largest_uid_skipped = None;
|
||||
let delete_target = context.get_delete_msgs_target().await?;
|
||||
|
||||
let download_limit: Option<u32> = context
|
||||
.get_config_parsed(Config::DownloadLimit)
|
||||
@@ -669,7 +659,7 @@ impl Imap {
|
||||
|
||||
let _target;
|
||||
let target = if delete {
|
||||
&delete_target
|
||||
""
|
||||
} else {
|
||||
_target = target_folder(context, folder, folder_meaning, &headers).await?;
|
||||
&_target
|
||||
@@ -836,27 +826,6 @@ impl Imap {
|
||||
|
||||
Ok((read_cnt, fetch_more))
|
||||
}
|
||||
|
||||
/// Read the recipients from old emails sent by the user and add them as contacts.
|
||||
/// This way, we can already offer them some email addresses they can write to.
|
||||
///
|
||||
/// Then, Fetch the last messages DC_FETCH_EXISTING_MSGS_COUNT emails from the server
|
||||
/// and show them in the chat list.
|
||||
pub(crate) async fn fetch_existing_msgs(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
) -> Result<()> {
|
||||
add_all_recipients_as_contacts(context, session, Config::ConfiguredMvboxFolder)
|
||||
.await
|
||||
.context("failed to get recipients from the movebox")?;
|
||||
add_all_recipients_as_contacts(context, session, Config::ConfiguredInboxFolder)
|
||||
.await
|
||||
.context("failed to get recipients from the inbox")?;
|
||||
|
||||
info!(context, "Done fetching existing messages.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -895,10 +864,7 @@ impl Session {
|
||||
// Collect pairs of UID and Message-ID.
|
||||
let mut msgs = BTreeMap::new();
|
||||
|
||||
let create = false;
|
||||
let folder_exists = self
|
||||
.select_with_uidvalidity(context, folder, create)
|
||||
.await?;
|
||||
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
|
||||
let transport_id = self.transport_id();
|
||||
if folder_exists {
|
||||
let mut list = self
|
||||
@@ -1020,17 +986,6 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
if context.should_delete_to_trash().await? {
|
||||
error!(
|
||||
context,
|
||||
"Cannot move messages {} to {}, no fallback to COPY/DELETE because \
|
||||
delete_to_trash is set. Error: {:#}",
|
||||
set,
|
||||
target,
|
||||
err,
|
||||
);
|
||||
return Err(err.into());
|
||||
}
|
||||
warn!(
|
||||
context,
|
||||
"Cannot move messages, fallback to COPY/DELETE {} to {}: {}",
|
||||
@@ -1044,19 +999,11 @@ impl Session {
|
||||
|
||||
// Server does not support MOVE or MOVE failed.
|
||||
// Copy messages to the destination folder if needed and mark records for deletion.
|
||||
let copy = !context.is_trash(target).await?;
|
||||
if copy {
|
||||
info!(
|
||||
context,
|
||||
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
|
||||
);
|
||||
self.uid_copy(&set, &target).await?;
|
||||
} else {
|
||||
error!(
|
||||
context,
|
||||
"Server does not support MOVE, fallback to DELETE {} to {}", set, target,
|
||||
);
|
||||
}
|
||||
info!(
|
||||
context,
|
||||
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
|
||||
);
|
||||
self.uid_copy(&set, &target).await?;
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
@@ -1068,11 +1015,9 @@ impl Session {
|
||||
})
|
||||
.await
|
||||
.context("Cannot plan deletion of messages")?;
|
||||
if copy {
|
||||
context.emit_event(EventType::ImapMessageMoved(format!(
|
||||
"IMAP messages {set} copied to {target}"
|
||||
)));
|
||||
}
|
||||
context.emit_event(EventType::ImapMessageMoved(format!(
|
||||
"IMAP messages {set} copied to {target}"
|
||||
)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1104,10 +1049,7 @@ impl Session {
|
||||
// MOVE/DELETE operations. This does not result in multiple SELECT commands
|
||||
// being sent because `select_folder()` does nothing if the folder is already
|
||||
// selected.
|
||||
let create = false;
|
||||
let folder_exists = self
|
||||
.select_with_uidvalidity(context, folder, create)
|
||||
.await?;
|
||||
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
|
||||
ensure!(folder_exists, "No folder {folder}");
|
||||
|
||||
// Empty target folder name means messages should be deleted.
|
||||
@@ -1139,7 +1081,6 @@ impl Session {
|
||||
/// Stores pending `\Seen` flags for messages in `imap_markseen` table.
|
||||
pub(crate) async fn store_seen_flags_on_imap(&mut self, context: &Context) -> Result<()> {
|
||||
if context.get_config_bool(Config::TeamProfile).await? {
|
||||
info!(context, "Team profile, skipping seen flag synchronization.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1163,8 +1104,7 @@ impl Session {
|
||||
.await?;
|
||||
|
||||
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
|
||||
let create = false;
|
||||
let folder_exists = match self.select_with_uidvalidity(context, &folder, create).await {
|
||||
let folder_exists = match self.select_with_uidvalidity(context, &folder).await {
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
@@ -1215,13 +1155,11 @@ impl Session {
|
||||
}
|
||||
|
||||
if context.get_config_bool(Config::TeamProfile).await? {
|
||||
info!(context, "Team profile, skipping seen flag synchronization.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let create = false;
|
||||
let folder_exists = self
|
||||
.select_with_uidvalidity(context, folder, create)
|
||||
.select_with_uidvalidity(context, folder)
|
||||
.await
|
||||
.context("Failed to select folder")?;
|
||||
if !folder_exists {
|
||||
@@ -1313,41 +1251,6 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the from, to and bcc addresses from all existing outgoing emails.
|
||||
pub async fn get_all_recipients(&mut self, context: &Context) -> Result<Vec<SingleInfo>> {
|
||||
let mut uids: Vec<_> = self
|
||||
.uid_search(get_imap_self_sent_search_command(context).await?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
uids.sort_unstable();
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (_, uid_set) in build_sequence_sets(&uids)? {
|
||||
let mut list = self
|
||||
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC)])")
|
||||
.await
|
||||
.context("IMAP Could not fetch")?;
|
||||
|
||||
while let Some(msg) = list.try_next().await? {
|
||||
match get_fetch_headers(&msg) {
|
||||
Ok(headers) => {
|
||||
if let Some(from) = mimeparser::get_from(&headers)
|
||||
&& context.is_self_addr(&from.addr).await?
|
||||
{
|
||||
result.extend(mimeparser::get_recipients(&headers));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(context, "{}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Fetches a list of messages by server UID.
|
||||
///
|
||||
/// Sends pairs of UID and info about each downloaded message to the provided channel.
|
||||
@@ -1552,7 +1455,7 @@ impl Session {
|
||||
if m.entry == "/shared/vendor/deltachat/turn"
|
||||
&& let Some(value) = m.value
|
||||
{
|
||||
match create_ice_servers_from_metadata(context, &value).await {
|
||||
match create_ice_servers_from_metadata(&value).await {
|
||||
Ok((parsed_timestamp, parsed_ice_servers)) => {
|
||||
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
|
||||
old_metadata.ice_servers = parsed_ice_servers;
|
||||
@@ -1569,7 +1472,7 @@ impl Session {
|
||||
info!(context, "Will use fallback ICE servers.");
|
||||
// Set expiration timestamp 7 days in the future so we don't request it again.
|
||||
old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
|
||||
old_metadata.ice_servers = create_fallback_ice_servers(context).await?;
|
||||
old_metadata.ice_servers = create_fallback_ice_servers();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1616,7 +1519,7 @@ impl Session {
|
||||
}
|
||||
"/shared/vendor/deltachat/turn" => {
|
||||
if let Some(value) = m.value {
|
||||
match create_ice_servers_from_metadata(context, &value).await {
|
||||
match create_ice_servers_from_metadata(&value).await {
|
||||
Ok((parsed_timestamp, parsed_ice_servers)) => {
|
||||
ice_servers_expiration_timestamp = parsed_timestamp;
|
||||
ice_servers = Some(parsed_ice_servers);
|
||||
@@ -1635,7 +1538,7 @@ impl Session {
|
||||
} else {
|
||||
// Set expiration timestamp 7 days in the future so we don't request it again.
|
||||
ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
|
||||
create_fallback_ice_servers(context).await?
|
||||
create_fallback_ice_servers()
|
||||
};
|
||||
|
||||
*lock = Some(ServerMetadata {
|
||||
@@ -1767,17 +1670,16 @@ impl Session {
|
||||
|
||||
/// Attempts to configure mvbox.
|
||||
///
|
||||
/// Tries to find any folder examining `folders` in the order they go. If none is found, tries
|
||||
/// to create any folder in the same order. This method does not use LIST command to ensure that
|
||||
/// Tries to find any folder examining `folders` in the order they go.
|
||||
/// This method does not use LIST command to ensure that
|
||||
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
|
||||
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
|
||||
///
|
||||
/// Returns first found or created folder name.
|
||||
/// Returns first found folder name.
|
||||
async fn configure_mvbox<'a>(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
folders: &[&'a str],
|
||||
create_mvbox: bool,
|
||||
) -> Result<Option<&'a str>> {
|
||||
// Close currently selected folder if needed.
|
||||
// We are going to select folders using low-level EXAMINE operations below.
|
||||
@@ -1794,34 +1696,12 @@ impl Session {
|
||||
self.close().await?;
|
||||
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
|
||||
// emails moved before that wouldn't be fetched but considered "old" instead.
|
||||
let create = false;
|
||||
let folder_exists = self
|
||||
.select_with_uidvalidity(context, folder, create)
|
||||
.await?;
|
||||
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
|
||||
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
|
||||
return Ok(Some(folder));
|
||||
}
|
||||
}
|
||||
|
||||
if !create_mvbox {
|
||||
return Ok(None);
|
||||
}
|
||||
// Some servers require namespace-style folder names like "INBOX.DeltaChat", so we try all
|
||||
// the variants here.
|
||||
for folder in folders {
|
||||
match self
|
||||
.select_with_uidvalidity(context, folder, create_mvbox)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!(context, "MVBOX-folder {} created.", folder);
|
||||
return Ok(Some(folder));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(context, "Cannot create MVBOX-folder {:?}: {}", folder, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -1831,7 +1711,6 @@ impl Imap {
|
||||
&mut self,
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
create_mvbox: bool,
|
||||
) -> Result<()> {
|
||||
let mut folders = session
|
||||
.list(Some(""), Some("*"))
|
||||
@@ -1872,7 +1751,7 @@ impl Imap {
|
||||
|
||||
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
|
||||
let mvbox_folder = session
|
||||
.configure_mvbox(context, &["DeltaChat", &fallback_folder], create_mvbox)
|
||||
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
|
||||
.await
|
||||
.context("failed to configure mvbox")?;
|
||||
|
||||
@@ -2468,18 +2347,6 @@ async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Resul
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Compute the imap search expression for all self-sent mails (for all self addresses)
|
||||
pub(crate) async fn get_imap_self_sent_search_command(context: &Context) -> Result<String> {
|
||||
// See https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4 for syntax of SEARCH and OR
|
||||
let mut search_command = format!("FROM \"{}\"", context.get_primary_self_addr().await?);
|
||||
|
||||
for item in context.get_secondary_self_addrs().await? {
|
||||
search_command = format!("OR ({search_command}) (FROM \"{item}\")");
|
||||
}
|
||||
|
||||
Ok(search_command)
|
||||
}
|
||||
|
||||
/// Whether to ignore fetching messages from a folder.
|
||||
///
|
||||
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
|
||||
@@ -2552,65 +2419,23 @@ impl std::fmt::Display for UidRange {
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn add_all_recipients_as_contacts(
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
folder: Config,
|
||||
) -> Result<()> {
|
||||
let mailbox = if let Some(m) = context.get_config(folder).await? {
|
||||
m
|
||||
} else {
|
||||
info!(
|
||||
context,
|
||||
"Folder {} is not configured, skipping fetching contacts from it.", folder
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let create = false;
|
||||
let folder_exists = session
|
||||
.select_with_uidvalidity(context, &mailbox, create)
|
||||
.await
|
||||
.with_context(|| format!("could not select {mailbox}"))?;
|
||||
if !folder_exists {
|
||||
return Ok(());
|
||||
|
||||
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
|
||||
let mut res = vec![Config::ConfiguredInboxFolder];
|
||||
if context.should_watch_mvbox().await? {
|
||||
res.push(Config::ConfiguredMvboxFolder);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
let recipients = session
|
||||
.get_all_recipients(context)
|
||||
.await
|
||||
.context("could not get recipients")?;
|
||||
|
||||
let mut any_modified = false;
|
||||
for recipient in recipients {
|
||||
let recipient_addr = match ContactAddress::new(&recipient.addr) {
|
||||
Err(err) => {
|
||||
warn!(
|
||||
context,
|
||||
"Could not add contact for recipient with address {:?}: {:#}",
|
||||
recipient.addr,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(recipient_addr) => recipient_addr,
|
||||
};
|
||||
|
||||
let (_, modified) = Contact::add_or_lookup(
|
||||
context,
|
||||
&recipient.display_name.unwrap_or_default(),
|
||||
&recipient_addr,
|
||||
Origin::OutgoingTo,
|
||||
)
|
||||
.await?;
|
||||
if modified != Modifier::None {
|
||||
any_modified = true;
|
||||
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
|
||||
let mut res = Vec::new();
|
||||
for folder_config in get_watched_folder_configs(context).await? {
|
||||
if let Some(folder) = context.get_config(folder_config).await? {
|
||||
res.push(folder);
|
||||
}
|
||||
}
|
||||
if any_modified {
|
||||
context.emit_event(EventType::ContactsChanged(None));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -27,9 +27,7 @@ impl Session {
|
||||
idle_interrupt_receiver: Receiver<()>,
|
||||
folder: &str,
|
||||
) -> Result<Self> {
|
||||
let create = true;
|
||||
self.select_with_uidvalidity(context, folder, create)
|
||||
.await?;
|
||||
self.select_with_uidvalidity(context, folder).await?;
|
||||
|
||||
if self.drain_unsolicited_responses(context)? {
|
||||
self.new_mail = true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use crate::contact::Contact;
|
||||
use crate::test_utils::TestContext;
|
||||
use crate::transport::add_pseudo_transport;
|
||||
|
||||
#[test]
|
||||
fn test_get_folder_meaning_by_name() {
|
||||
@@ -264,31 +264,6 @@ async fn test_target_folder_setupmsg() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_get_imap_search_command() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
assert_eq!(
|
||||
get_imap_self_sent_search_command(&t.ctx).await?,
|
||||
r#"FROM "alice@example.org""#
|
||||
);
|
||||
|
||||
add_pseudo_transport(&t, "alice@another.com").await?;
|
||||
t.ctx.set_primary_self_addr("alice@another.com").await?;
|
||||
assert_eq!(
|
||||
get_imap_self_sent_search_command(&t.ctx).await?,
|
||||
r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"#
|
||||
);
|
||||
|
||||
add_pseudo_transport(&t, "alice@third.com").await?;
|
||||
t.ctx.set_primary_self_addr("alice@third.com").await?;
|
||||
assert_eq!(
|
||||
get_imap_self_sent_search_command(&t.ctx).await?,
|
||||
r#"OR (OR (FROM "alice@third.com") (FROM "alice@another.com")) (FROM "alice@example.org")"#
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uid_grouper() {
|
||||
// Input: sequence of (rowid: i64, uid: u32, target: String)
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use super::{get_folder_meaning_by_attrs, get_folder_meaning_by_name};
|
||||
use crate::config::Config;
|
||||
use crate::imap::{Imap, session::Session};
|
||||
use crate::log::LogExt;
|
||||
use crate::tools::{self, time_elapsed};
|
||||
use crate::{context::Context, imap::FolderMeaning};
|
||||
|
||||
impl Imap {
|
||||
/// Returns true if folders were scanned, false if scanning was postponed.
|
||||
pub(crate) async fn scan_folders(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
session: &mut Session,
|
||||
) -> Result<bool> {
|
||||
// First of all, debounce to once per minute:
|
||||
{
|
||||
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
|
||||
.get_config_u64(Config::ScanAllFoldersDebounceSecs)
|
||||
.await?;
|
||||
|
||||
if elapsed_secs < debounce_secs {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the timestamp before scanning the folders
|
||||
// to avoid holding the lock for too long.
|
||||
// This means next scan is delayed even if
|
||||
// the current one fails.
|
||||
last_scan.replace(tools::Time::now());
|
||||
}
|
||||
info!(context, "Starting full folder scan");
|
||||
|
||||
let folders = session.list_folders().await?;
|
||||
let watched_folders = get_watched_folders(context).await?;
|
||||
|
||||
let mut folder_configs = BTreeMap::new();
|
||||
let mut folder_names = Vec::new();
|
||||
|
||||
for folder in folders {
|
||||
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
|
||||
if folder_meaning == FolderMeaning::Virtual {
|
||||
// Gmail has virtual folders that should be skipped. For example,
|
||||
// emails appear in the inbox and under "All Mail" as soon as it is
|
||||
// received. The code used to wrongly conclude that the email had
|
||||
// already been moved and left it in the inbox.
|
||||
continue;
|
||||
}
|
||||
folder_names.push(folder.name().to_string());
|
||||
let folder_name_meaning = get_folder_meaning_by_name(folder.name());
|
||||
|
||||
if let Some(config) = folder_meaning.to_config() {
|
||||
// Always takes precedence
|
||||
folder_configs.insert(config, folder.name().to_string());
|
||||
} else if let Some(config) = folder_name_meaning.to_config() {
|
||||
// only set if none has been already set
|
||||
folder_configs
|
||||
.entry(config)
|
||||
.or_insert_with(|| folder.name().to_string());
|
||||
}
|
||||
|
||||
let folder_meaning = match folder_meaning {
|
||||
FolderMeaning::Unknown => folder_name_meaning,
|
||||
_ => folder_meaning,
|
||||
};
|
||||
|
||||
// Don't scan folders that are watched anyway
|
||||
if !watched_folders.contains(&folder.name().to_string())
|
||||
&& folder_meaning != FolderMeaning::Trash
|
||||
&& folder_meaning != FolderMeaning::Unknown
|
||||
{
|
||||
self.fetch_move_delete(context, session, folder.name(), folder_meaning)
|
||||
.await
|
||||
.context("Can't fetch new msgs in scanned folder")
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Set config for the Trash folder. Or reset if the folder was deleted.
|
||||
let conf = Config::ConfiguredTrashFolder;
|
||||
let val = folder_configs.get(&conf).map(|s| s.as_str());
|
||||
let interrupt = val.is_some() && context.get_config(conf).await?.is_none();
|
||||
context.set_config_internal(conf, val).await?;
|
||||
if interrupt {
|
||||
// `Imap::fetch_move_delete()`, particularly message deletion, is possible now for other
|
||||
// folders (NB: we are in the Inbox loop).
|
||||
context.scheduler.interrupt_oboxes().await;
|
||||
}
|
||||
|
||||
info!(context, "Found folders: {folder_names:?}.");
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
|
||||
let mut res = vec![Config::ConfiguredInboxFolder];
|
||||
if context.should_watch_mvbox().await? {
|
||||
res.push(Config::ConfiguredMvboxFolder);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
|
||||
let mut res = Vec::new();
|
||||
for folder_config in get_watched_folder_configs(context).await? {
|
||||
if let Some(folder) = context.get_config(folder_config).await? {
|
||||
res.push(folder);
|
||||
}
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
@@ -89,33 +89,6 @@ impl ImapSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects a folder. Tries to create it once and select again if the folder does not exist.
|
||||
pub(super) async fn select_or_create_folder(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
folder: &str,
|
||||
) -> anyhow::Result<NewlySelected> {
|
||||
match self.select_folder(context, folder).await {
|
||||
Ok(newly_selected) => Ok(newly_selected),
|
||||
Err(err) => match err {
|
||||
Error::NoFolder(..) => {
|
||||
info!(context, "Failed to select folder {folder:?} because it does not exist, trying to create it.");
|
||||
let create_res = self.create(folder).await;
|
||||
if let Err(ref err) = create_res {
|
||||
info!(context, "Couldn't select folder, then create() failed: {err:#}.");
|
||||
// Need to recheck, could have been created in parallel.
|
||||
}
|
||||
let select_res = self.select_folder(context, folder).await.with_context(|| format!("failed to select newely created folder {folder}"));
|
||||
if select_res.is_err() {
|
||||
create_res?;
|
||||
}
|
||||
select_res
|
||||
}
|
||||
_ => Err(err).with_context(|| format!("failed to select folder {folder} with error other than NO, not trying to create it")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects a folder optionally creating it and takes care of UIDVALIDITY changes. Returns false
|
||||
/// iff `folder` doesn't exist.
|
||||
///
|
||||
@@ -129,23 +102,16 @@ impl ImapSession {
|
||||
&mut self,
|
||||
context: &Context,
|
||||
folder: &str,
|
||||
create: bool,
|
||||
) -> anyhow::Result<bool> {
|
||||
let newly_selected = if create {
|
||||
self.select_or_create_folder(context, folder)
|
||||
.await
|
||||
.with_context(|| format!("Failed to select or create folder {folder:?}"))?
|
||||
} else {
|
||||
match self.select_folder(context, folder).await {
|
||||
Ok(newly_selected) => newly_selected,
|
||||
Err(err) => match err {
|
||||
Error::NoFolder(..) => return Ok(false),
|
||||
_ => {
|
||||
return Err(err)
|
||||
.with_context(|| format!("Failed to select folder {folder:?}"))?;
|
||||
}
|
||||
},
|
||||
}
|
||||
let newly_selected = match self.select_folder(context, folder).await {
|
||||
Ok(newly_selected) => newly_selected,
|
||||
Err(err) => match err {
|
||||
Error::NoFolder(..) => return Ok(false),
|
||||
_ => {
|
||||
return Err(err)
|
||||
.with_context(|| format!("Failed to select folder {folder:?}"))?;
|
||||
}
|
||||
},
|
||||
};
|
||||
let transport_id = self.transport_id();
|
||||
|
||||
|
||||
@@ -5,11 +5,9 @@ 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.
|
||||
@@ -46,8 +44,6 @@ 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.
|
||||
@@ -84,7 +80,6 @@ impl Session {
|
||||
selected_folder: None,
|
||||
selected_mailbox: None,
|
||||
selected_folder_needs_expunge: false,
|
||||
last_full_folder_scan: Mutex::new(None),
|
||||
new_mail: false,
|
||||
resync_request_sender,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::e2ee;
|
||||
use crate::events::EventType;
|
||||
use crate::key::{self, DcKey, DcSecretKey, SignedPublicKey, SignedSecretKey};
|
||||
use crate::key::{self, DcKey, SignedPublicKey, SignedSecretKey};
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::pgp;
|
||||
use crate::qr::DCBACKUP_VERSION;
|
||||
@@ -142,7 +142,7 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
|
||||
|
||||
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
|
||||
let private_key = SignedSecretKey::from_asc(armored)?;
|
||||
let public_key = private_key.split_public_key()?;
|
||||
let public_key = private_key.to_public_key();
|
||||
|
||||
let keypair = pgp::KeyPair {
|
||||
public: public_key,
|
||||
@@ -153,7 +153,7 @@ async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
|
||||
info!(
|
||||
context,
|
||||
"stored self key: {:?}",
|
||||
keypair.secret.public_key().key_id()
|
||||
keypair.secret.public_key().legacy_key_id()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
61
src/key.rs
61
src/key.rs
@@ -10,7 +10,7 @@ use deltachat_contact_tools::EmailAddress;
|
||||
use pgp::composed::Deserializable;
|
||||
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::{KeyDetails, KeyId, Password};
|
||||
use pgp::types::{KeyDetails, KeyId};
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::context::Context;
|
||||
@@ -156,24 +156,14 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result<SignedPubl
|
||||
}
|
||||
|
||||
/// Returns our own public keyring.
|
||||
///
|
||||
/// No keys are generated and at most one key is returned.
|
||||
pub(crate) async fn load_self_public_keyring(context: &Context) -> Result<Vec<SignedPublicKey>> {
|
||||
let keys = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
r#"SELECT public_key
|
||||
FROM keypairs
|
||||
ORDER BY id=(SELECT value FROM config WHERE keyname='key_id') DESC"#,
|
||||
(),
|
||||
|row| {
|
||||
let public_key_bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(public_key_bytes)
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|bytes| SignedPublicKey::from_slice(&bytes).log_err(context).ok())
|
||||
.collect();
|
||||
Ok(keys)
|
||||
if let Some(public_key) = load_self_public_key_opt(context).await? {
|
||||
Ok(vec![public_key])
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns own public key fingerprint in (not human-readable) hex representation.
|
||||
@@ -274,7 +264,7 @@ impl DcKey for SignedPublicKey {
|
||||
}
|
||||
|
||||
fn key_id(&self) -> KeyId {
|
||||
KeyDetails::key_id(self)
|
||||
KeyDetails::legacy_key_id(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,30 +291,7 @@ impl DcKey for SignedSecretKey {
|
||||
}
|
||||
|
||||
fn key_id(&self) -> KeyId {
|
||||
KeyDetails::key_id(&**self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deltachat extension trait for secret keys.
|
||||
///
|
||||
/// Provides some convenience wrappers only applicable to [SignedSecretKey].
|
||||
pub(crate) trait DcSecretKey {
|
||||
/// Create a public key from a private one.
|
||||
fn split_public_key(&self) -> Result<SignedPublicKey>;
|
||||
}
|
||||
|
||||
impl DcSecretKey for SignedSecretKey {
|
||||
fn split_public_key(&self) -> Result<SignedPublicKey> {
|
||||
self.verify()?;
|
||||
let unsigned_pubkey = self.public_key();
|
||||
let mut rng = rand_old::thread_rng();
|
||||
let signed_pubkey = unsigned_pubkey.sign(
|
||||
&mut rng,
|
||||
&self.primary_key,
|
||||
self.primary_key.public_key(),
|
||||
&Password::empty(),
|
||||
)?;
|
||||
Ok(signed_pubkey)
|
||||
KeyDetails::legacy_key_id(&**self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +403,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
|
||||
/// Use import/export APIs instead.
|
||||
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
|
||||
let secret = SignedSecretKey::from_asc(secret_data)?;
|
||||
let public = secret.split_public_key()?;
|
||||
let public = secret.to_public_key();
|
||||
let keypair = KeyPair { public, secret };
|
||||
store_self_keypair(context, &keypair).await?;
|
||||
Ok(())
|
||||
@@ -712,12 +679,6 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
assert_eq!(res0.unwrap(), res1.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_key() {
|
||||
let pubkey = KEYPAIR.secret.split_public_key().unwrap();
|
||||
assert_eq!(pubkey.primary_key, KEYPAIR.public.primary_key);
|
||||
}
|
||||
|
||||
/// Tests that setting a default key second time is not allowed.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_save_self_key_twice() {
|
||||
|
||||
127
src/message.rs
127
src/message.rs
@@ -87,12 +87,10 @@ impl MsgId {
|
||||
let result = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
concat!(
|
||||
"SELECT m.state, mdns.msg_id",
|
||||
" FROM msgs m LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
|
||||
" WHERE id=?",
|
||||
" LIMIT 1",
|
||||
),
|
||||
"SELECT m.state, mdns.msg_id
|
||||
FROM msgs m LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
|
||||
WHERE id=?
|
||||
LIMIT 1",
|
||||
(self,),
|
||||
|row| {
|
||||
let state: MessageState = row.get(0)?;
|
||||
@@ -454,6 +452,7 @@ pub struct Message {
|
||||
pub(crate) is_dc_message: MessengerMessage,
|
||||
pub(crate) original_msg_id: MsgId,
|
||||
pub(crate) mime_modified: bool,
|
||||
pub(crate) chat_visibility: ChatVisibility,
|
||||
pub(crate) chat_blocked: Blocked,
|
||||
pub(crate) location_id: u32,
|
||||
pub(crate) error: Option<String>,
|
||||
@@ -501,40 +500,39 @@ impl Message {
|
||||
let mut msg = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
concat!(
|
||||
"SELECT",
|
||||
" m.id AS id,",
|
||||
" rfc724_mid AS rfc724mid,",
|
||||
" pre_rfc724_mid AS pre_rfc724mid,",
|
||||
" m.mime_in_reply_to AS mime_in_reply_to,",
|
||||
" m.chat_id AS chat_id,",
|
||||
" m.from_id AS from_id,",
|
||||
" m.to_id AS to_id,",
|
||||
" m.timestamp AS timestamp,",
|
||||
" m.timestamp_sent AS timestamp_sent,",
|
||||
" m.timestamp_rcvd AS timestamp_rcvd,",
|
||||
" m.ephemeral_timer AS ephemeral_timer,",
|
||||
" m.ephemeral_timestamp AS ephemeral_timestamp,",
|
||||
" m.type AS type,",
|
||||
" m.state AS state,",
|
||||
" mdns.msg_id AS mdn_msg_id,",
|
||||
" m.download_state AS download_state,",
|
||||
" m.error AS error,",
|
||||
" m.msgrmsg AS msgrmsg,",
|
||||
" m.starred AS original_msg_id,",
|
||||
" m.mime_modified AS mime_modified,",
|
||||
" m.txt AS txt,",
|
||||
" m.subject AS subject,",
|
||||
" m.param AS param,",
|
||||
" m.hidden AS hidden,",
|
||||
" m.location_id AS location,",
|
||||
" c.blocked AS blocked",
|
||||
" FROM msgs m",
|
||||
" LEFT JOIN chats c ON c.id=m.chat_id",
|
||||
" LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
|
||||
" WHERE m.id=? AND chat_id!=3",
|
||||
" LIMIT 1",
|
||||
),
|
||||
"SELECT
|
||||
m.id AS id,
|
||||
rfc724_mid AS rfc724mid,
|
||||
pre_rfc724_mid AS pre_rfc724mid,
|
||||
m.mime_in_reply_to AS mime_in_reply_to,
|
||||
m.chat_id AS chat_id,
|
||||
m.from_id AS from_id,
|
||||
m.to_id AS to_id,
|
||||
m.timestamp AS timestamp,
|
||||
m.timestamp_sent AS timestamp_sent,
|
||||
m.timestamp_rcvd AS timestamp_rcvd,
|
||||
m.ephemeral_timer AS ephemeral_timer,
|
||||
m.ephemeral_timestamp AS ephemeral_timestamp,
|
||||
m.type AS type,
|
||||
m.state AS state,
|
||||
mdns.msg_id AS mdn_msg_id,
|
||||
m.download_state AS download_state,
|
||||
m.error AS error,
|
||||
m.msgrmsg AS msgrmsg,
|
||||
m.starred AS original_msg_id,
|
||||
m.mime_modified AS mime_modified,
|
||||
m.txt AS txt,
|
||||
m.subject AS subject,
|
||||
m.param AS param,
|
||||
m.hidden AS hidden,
|
||||
m.location_id AS location,
|
||||
c.archived AS visibility,
|
||||
c.blocked AS blocked
|
||||
FROM msgs m
|
||||
LEFT JOIN chats c ON c.id=m.chat_id
|
||||
LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
|
||||
WHERE m.id=? AND chat_id!=3
|
||||
LIMIT 1",
|
||||
(id,),
|
||||
|row| {
|
||||
let state: MessageState = row.get("state")?;
|
||||
@@ -587,6 +585,7 @@ impl Message {
|
||||
param: row.get::<_, String>("param")?.parse().unwrap_or_default(),
|
||||
hidden: row.get("hidden")?,
|
||||
location_id: row.get("location")?,
|
||||
chat_visibility: row.get::<_, Option<_>>("visibility")?.unwrap_or_default(),
|
||||
chat_blocked: row
|
||||
.get::<_, Option<Blocked>>("blocked")?
|
||||
.unwrap_or_default(),
|
||||
@@ -981,7 +980,7 @@ impl Message {
|
||||
|
||||
/// Returns true if the message is a forwarded message.
|
||||
pub fn is_forwarded(&self) -> bool {
|
||||
0 != self.param.get_int(Param::Forwarded).unwrap_or_default()
|
||||
self.param.get_int(Param::Forwarded).is_some()
|
||||
}
|
||||
|
||||
/// Returns true if the message is edited.
|
||||
@@ -1507,6 +1506,16 @@ pub async fn get_msg_read_receipts(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns count of read receipts on message.
|
||||
///
|
||||
/// This view count is meant as a feedback measure for the channel owner only.
|
||||
pub async fn get_msg_read_receipt_count(context: &Context, msg_id: MsgId) -> Result<usize> {
|
||||
context
|
||||
.sql
|
||||
.count("SELECT COUNT(*) FROM msgs_mdns WHERE msg_id=?", (msg_id,))
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn guess_msgtype_from_suffix(msg: &Message) -> Option<(Viewtype, &'static str)> {
|
||||
msg.param
|
||||
.get(Param::Filename)
|
||||
@@ -1753,12 +1762,11 @@ pub async fn delete_msgs_ex(
|
||||
modified_chat_ids.insert(msg.chat_id);
|
||||
deleted_rfc724_mid.push(msg.rfc724_mid.clone());
|
||||
|
||||
let target = context.get_delete_msgs_target().await?;
|
||||
let update_db = |trans: &mut rusqlite::Transaction| {
|
||||
let mut stmt = trans.prepare("UPDATE imap SET target=? WHERE rfc724_mid=?")?;
|
||||
stmt.execute((&target, &msg.rfc724_mid))?;
|
||||
let mut stmt = trans.prepare("UPDATE imap SET target='' WHERE rfc724_mid=?")?;
|
||||
stmt.execute((&msg.rfc724_mid,))?;
|
||||
if !msg.pre_rfc724_mid.is_empty() {
|
||||
stmt.execute((&target, &msg.pre_rfc724_mid))?;
|
||||
stmt.execute((&msg.pre_rfc724_mid,))?;
|
||||
}
|
||||
trans.execute("DELETE FROM smtp WHERE msg_id=?", (msg_id,))?;
|
||||
trans.execute(
|
||||
@@ -1841,6 +1849,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
m.param AS param,
|
||||
m.from_id AS from_id,
|
||||
m.rfc724_mid AS rfc724_mid,
|
||||
m.hidden AS hidden,
|
||||
c.archived AS archived,
|
||||
c.blocked AS blocked
|
||||
FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id
|
||||
@@ -1852,6 +1861,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
|
||||
let from_id: ContactId = row.get("from_id")?;
|
||||
let rfc724_mid: String = row.get("rfc724_mid")?;
|
||||
let hidden: bool = row.get("hidden")?;
|
||||
let visibility: ChatVisibility = row.get("archived")?;
|
||||
let blocked: Option<Blocked> = row.get("blocked")?;
|
||||
let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
|
||||
@@ -1863,6 +1873,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
param,
|
||||
from_id,
|
||||
rfc724_mid,
|
||||
hidden,
|
||||
visibility,
|
||||
blocked.unwrap_or_default(),
|
||||
),
|
||||
@@ -1895,6 +1906,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
curr_param,
|
||||
curr_from_id,
|
||||
curr_rfc724_mid,
|
||||
curr_hidden,
|
||||
curr_visibility,
|
||||
curr_blocked,
|
||||
),
|
||||
@@ -1907,8 +1919,9 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
|
||||
markseen_on_imap_table(context, &curr_rfc724_mid).await?;
|
||||
|
||||
// Read receipts for system messages are never sent. These messages have no place to
|
||||
// display received read receipt anyway. And since their text is locally generated,
|
||||
// Read receipts for system messages are never sent to contacts.
|
||||
// These messages have no place to display received read receipt
|
||||
// anyway. And since their text is locally generated,
|
||||
// quoting them is dangerous as it may contain contact names. E.g., for original message
|
||||
// "Group left by me", a read receipt will quote "Group left by <name>", and the name can
|
||||
// be a display name stored in address book rather than the name sent in the From field by
|
||||
@@ -1916,25 +1929,35 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|
||||
//
|
||||
// We also don't send read receipts for contact requests.
|
||||
// Read receipts will not be sent even after accepting the chat.
|
||||
if curr_blocked == Blocked::Not
|
||||
let to_id = if curr_blocked == Blocked::Not
|
||||
&& curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
|
||||
&& curr_param.get_cmd() == SystemMessage::Unknown
|
||||
&& context.should_send_mdns().await?
|
||||
{
|
||||
Some(curr_from_id)
|
||||
} else if context.get_config_bool(Config::BccSelf).await? {
|
||||
Some(ContactId::SELF)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(to_id) = to_id {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO smtp_mdns (msg_id, from_id, rfc724_mid) VALUES(?, ?, ?)",
|
||||
(id, curr_from_id, curr_rfc724_mid),
|
||||
(id, to_id, curr_rfc724_mid),
|
||||
)
|
||||
.await
|
||||
.context("failed to insert into smtp_mdns")?;
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
updated_chat_ids.insert(curr_chat_id);
|
||||
if !curr_hidden {
|
||||
updated_chat_ids.insert(curr_chat_id);
|
||||
}
|
||||
}
|
||||
archived_chats_maybe_noticed |=
|
||||
curr_state == MessageState::InFresh && curr_visibility == ChatVisibility::Archived;
|
||||
archived_chats_maybe_noticed |= curr_state == MessageState::InFresh
|
||||
&& !curr_hidden
|
||||
&& curr_visibility == ChatVisibility::Archived;
|
||||
}
|
||||
|
||||
for updated_chat_id in updated_chat_ids {
|
||||
|
||||
@@ -106,6 +106,7 @@ pub struct MimeFactory {
|
||||
/// addresses and OpenPGP keys
|
||||
/// to use for encryption.
|
||||
///
|
||||
/// If `Some`, encrypt to self also.
|
||||
/// `None` if the message is not encrypted.
|
||||
encryption_pubkeys: Option<Vec<(String, SignedPublicKey)>>,
|
||||
|
||||
@@ -234,7 +235,6 @@ impl MimeFactory {
|
||||
encryption_pubkeys = if msg.param.get_bool(Param::ForcePlaintext).unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
// Encrypt, but only to self.
|
||||
Some(Vec::new())
|
||||
};
|
||||
} else if chat.is_mailing_list() {
|
||||
@@ -247,6 +247,37 @@ impl MimeFactory {
|
||||
|
||||
// Do not encrypt messages to mailing lists.
|
||||
encryption_pubkeys = None;
|
||||
} else if let Some(fp) = must_have_only_one_recipient(&msg, &chat) {
|
||||
let fp = fp?;
|
||||
// In a broadcast channel, only send member-added/removed messages
|
||||
// to the affected member
|
||||
let (authname, addr) = context
|
||||
.sql
|
||||
.query_row(
|
||||
"SELECT authname, addr FROM contacts WHERE fingerprint=?",
|
||||
(fp,),
|
||||
|row| {
|
||||
let authname: String = row.get(0)?;
|
||||
let addr: String = row.get(1)?;
|
||||
Ok((authname, addr))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let public_key_bytes: Vec<_> = context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT public_key FROM public_keys WHERE fingerprint=?",
|
||||
(fp,),
|
||||
)
|
||||
.await?
|
||||
.context("Can't send member addition/removal: missing key")?;
|
||||
|
||||
recipients.push(addr.clone());
|
||||
to.push((authname, addr.clone()));
|
||||
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
encryption_pubkeys = Some(vec![(addr, public_key)]);
|
||||
} else {
|
||||
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
|
||||
msg.param.get(Param::Arg)
|
||||
@@ -306,13 +337,6 @@ impl MimeFactory {
|
||||
for row in rows {
|
||||
let (authname, addr, fingerprint, id, add_timestamp, remove_timestamp, public_key_bytes_opt) = row?;
|
||||
|
||||
// In a broadcast channel, only send member-added/removed messages
|
||||
// to the affected member:
|
||||
if let Some(fp) = must_have_only_one_recipient(&msg, &chat)
|
||||
&& fp? != fingerprint {
|
||||
continue;
|
||||
}
|
||||
|
||||
let public_key_opt = if let Some(public_key_bytes) = &public_key_bytes_opt {
|
||||
Some(SignedPublicKey::from_slice(public_key_bytes)?)
|
||||
} else {
|
||||
@@ -426,8 +450,16 @@ impl MimeFactory {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let recipient_ids: Vec<_> = recipient_ids.into_iter().collect();
|
||||
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
|
||||
let recipient_ids: Vec<_> = recipient_ids
|
||||
.into_iter()
|
||||
.filter(|id| *id != ContactId::SELF)
|
||||
.collect();
|
||||
if recipient_ids.len() == 1
|
||||
&& msg.param.get_cmd() != SystemMessage::MemberRemovedFromGroup
|
||||
&& chat.typ != Chattype::OutBroadcast
|
||||
{
|
||||
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
|
||||
}
|
||||
|
||||
if !msg.is_system_message()
|
||||
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
|
||||
@@ -531,7 +563,9 @@ impl MimeFactory {
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
|
||||
let addr = contact.get_addr().to_string();
|
||||
let encryption_pubkeys = if contact.is_key_contact() {
|
||||
let encryption_pubkeys = if from_id == ContactId::SELF {
|
||||
Some(Vec::new())
|
||||
} else if contact.is_key_contact() {
|
||||
if let Some(key) = contact.public_key(context).await? {
|
||||
Some(vec![(addr.clone(), key)])
|
||||
} else {
|
||||
@@ -1806,6 +1840,12 @@ impl MimeFactory {
|
||||
mail_builder::headers::raw::Raw::new(b_encode(answer)).into(),
|
||||
));
|
||||
}
|
||||
if let Some(has_video) = msg.param.get(Param::WebrtcHasVideoInitially) {
|
||||
headers.push((
|
||||
"Chat-Webrtc-Has-Video-Initially",
|
||||
mail_builder::headers::raw::Raw::new(b_encode(has_video)).into(),
|
||||
))
|
||||
}
|
||||
|
||||
if msg.viewtype == Viewtype::Voice
|
||||
|| msg.viewtype == Viewtype::Audio
|
||||
@@ -1900,15 +1940,19 @@ impl MimeFactory {
|
||||
|
||||
let mut parts = Vec::new();
|
||||
|
||||
// add HTML-part, this is needed only if a HTML-message from a non-delta-client is forwarded;
|
||||
// for simplificity and to avoid conversion errors, we're generating the HTML-part from the original message.
|
||||
if msg.has_html() {
|
||||
let html = if let Some(orig_msg_id) = msg.param.get_int(Param::Forwarded) {
|
||||
let html = if let Some(html) = msg.param.get(Param::SendHtml) {
|
||||
Some(html.to_string())
|
||||
} else if let Some(orig_msg_id) = msg.param.get_int(Param::Forwarded)
|
||||
&& orig_msg_id != 0
|
||||
{
|
||||
// Legacy forwarded messages may not have `Param::SendHtml` set. Let's hope the
|
||||
// original message exists.
|
||||
MsgId::new(orig_msg_id.try_into()?)
|
||||
.get_html(context)
|
||||
.await?
|
||||
} else {
|
||||
msg.param.get(Param::SendHtml).map(|s| s.to_string())
|
||||
None
|
||||
};
|
||||
if let Some(html) = html {
|
||||
main_part = MimePart::new(
|
||||
|
||||
@@ -89,11 +89,12 @@ pub(crate) struct MimeMessage {
|
||||
pub decrypting_failed: bool,
|
||||
|
||||
/// Valid signature fingerprint if a message is an
|
||||
/// Autocrypt encrypted and signed message.
|
||||
/// Autocrypt encrypted and signed message and corresponding intended recipient fingerprints
|
||||
/// (<https://www.rfc-editor.org/rfc/rfc9580.html#name-intended-recipient-fingerpr>) if any.
|
||||
///
|
||||
/// If a message is not encrypted or the signature is not valid,
|
||||
/// this is `None`.
|
||||
pub signature: Option<Fingerprint>,
|
||||
pub signature: Option<(Fingerprint, HashSet<Fingerprint>)>,
|
||||
|
||||
/// The addresses for which there was a gossip header
|
||||
/// and their respective gossiped keys.
|
||||
@@ -495,8 +496,7 @@ impl MimeMessage {
|
||||
// We don't decompress messages compressed multiple times.
|
||||
None
|
||||
}
|
||||
Some(pgp::composed::Message::SignedOnePass { reader, .. }) => reader.signature(),
|
||||
Some(pgp::composed::Message::Signed { reader, .. }) => Some(reader.signature()),
|
||||
Some(pgp::composed::Message::Signed { reader, .. }) => reader.signature(0),
|
||||
Some(pgp::composed::Message::Encrypted { .. }) => {
|
||||
// The message is already decrypted once.
|
||||
None
|
||||
@@ -529,12 +529,16 @@ impl MimeMessage {
|
||||
let mut signatures = if let Some(ref decrypted_msg) = decrypted_msg {
|
||||
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)
|
||||
} else {
|
||||
HashSet::new()
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
let mail = mail.as_ref().map(|mail| {
|
||||
let (content, signatures_detached) = validate_detached_signature(mail, &public_keyring)
|
||||
.unwrap_or((mail, Default::default()));
|
||||
let signatures_detached = signatures_detached
|
||||
.into_iter()
|
||||
.map(|fp| (fp, Vec::new()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
signatures.extend(signatures_detached);
|
||||
content
|
||||
});
|
||||
@@ -640,6 +644,10 @@ impl MimeMessage {
|
||||
};
|
||||
}
|
||||
|
||||
let signature = signatures
|
||||
.into_iter()
|
||||
.last()
|
||||
.map(|(fp, recipient_fps)| (fp, recipient_fps.into_iter().collect::<HashSet<_>>()));
|
||||
let mut parser = MimeMessage {
|
||||
parts: Vec::new(),
|
||||
headers,
|
||||
@@ -655,7 +663,7 @@ impl MimeMessage {
|
||||
decrypting_failed: mail.is_err(),
|
||||
|
||||
// only non-empty if it was a valid autocrypt message
|
||||
signature: signatures.into_iter().last(),
|
||||
signature,
|
||||
autocrypt_fingerprint,
|
||||
gossiped_keys,
|
||||
is_forwarded: false,
|
||||
@@ -792,6 +800,9 @@ impl MimeMessage {
|
||||
let accepted = self
|
||||
.get_header(HeaderDef::ChatWebrtcAccepted)
|
||||
.map(|s| s.to_string());
|
||||
let has_video = self
|
||||
.get_header(HeaderDef::ChatWebrtcHasVideoInitially)
|
||||
.map(|s| s.to_string());
|
||||
if let Some(part) = self.parts.first_mut() {
|
||||
if let Some(room) = room {
|
||||
if content == "call" {
|
||||
@@ -801,6 +812,9 @@ impl MimeMessage {
|
||||
} else if let Some(accepted) = accepted {
|
||||
part.param.set(Param::WebrtcAccepted, accepted);
|
||||
}
|
||||
if let Some(has_video) = has_video {
|
||||
part.param.set(Param::WebrtcHasVideoInitially, has_video);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1621,7 +1635,7 @@ impl MimeMessage {
|
||||
}
|
||||
Ok(key) => key,
|
||||
};
|
||||
if let Err(err) = key.verify() {
|
||||
if let Err(err) = key.verify_bindings() {
|
||||
warn!(context, "Attached PGP key verification failed: {err:#}.");
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -2159,9 +2173,9 @@ pub(crate) struct Report {
|
||||
///
|
||||
/// It MUST be present if the original message has a Message-ID according to RFC 8098.
|
||||
/// In case we can't find it (shouldn't happen), this is None.
|
||||
original_message_id: Option<String>,
|
||||
pub original_message_id: Option<String>,
|
||||
/// Additional-Message-IDs
|
||||
additional_message_ids: Vec<String>,
|
||||
pub additional_message_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Delivery Status Notification (RFC 3464, RFC 6533)
|
||||
@@ -2468,31 +2482,23 @@ async fn handle_mdn(
|
||||
timestamp_sent: i64,
|
||||
) -> Result<()> {
|
||||
if from_id == ContactId::SELF {
|
||||
warn!(
|
||||
context,
|
||||
"Ignoring MDN sent to self, this is a bug on the sender device."
|
||||
);
|
||||
|
||||
// This is not an error on our side,
|
||||
// we successfully ignored an invalid MDN and return `Ok`.
|
||||
// MDNs to self are handled in receive_imf_inner().
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some((msg_id, chat_id, has_mdns, is_dup)) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
concat!(
|
||||
"SELECT",
|
||||
" m.id AS msg_id,",
|
||||
" c.id AS chat_id,",
|
||||
" mdns.contact_id AS mdn_contact",
|
||||
" FROM msgs m ",
|
||||
" LEFT JOIN chats c ON m.chat_id=c.id",
|
||||
" LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
|
||||
" WHERE rfc724_mid=? AND from_id=1",
|
||||
" ORDER BY msg_id DESC, mdn_contact=? DESC",
|
||||
" LIMIT 1",
|
||||
),
|
||||
"SELECT
|
||||
m.id AS msg_id,
|
||||
c.id AS chat_id,
|
||||
mdns.contact_id AS mdn_contact
|
||||
FROM msgs m
|
||||
LEFT JOIN chats c ON m.chat_id=c.id
|
||||
LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
|
||||
WHERE rfc724_mid=? AND from_id=1
|
||||
ORDER BY msg_id DESC, mdn_contact=? DESC
|
||||
LIMIT 1",
|
||||
(&rfc724_mid, from_id),
|
||||
|row| {
|
||||
let msg_id: MsgId = row.get("msg_id")?;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use mailparse::ParsedMail;
|
||||
use std::mem;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
chat,
|
||||
chatlist::Chatlist,
|
||||
constants::{self, Blocked, DC_DESIRED_TEXT_LEN, DC_ELLIPSIS},
|
||||
key,
|
||||
message::{MessageState, MessengerMessage},
|
||||
receive_imf::receive_imf,
|
||||
test_utils::{TestContext, TestContextManager},
|
||||
@@ -1420,6 +1422,40 @@ async fn test_extra_imf_headers() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_intended_recipient_fingerprint() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.alice().await;
|
||||
let t_fp = key::load_self_public_key(t).await?.dc_fingerprint();
|
||||
t.set_config_bool(Config::BccSelf, false).await.unwrap();
|
||||
let members = [tcm.bob().await, tcm.fiona().await];
|
||||
let chat_id = chat::create_group(t, "").await?;
|
||||
|
||||
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
|
||||
assert!(t.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
let contact = t.add_or_lookup_contact(member).await;
|
||||
chat::add_contact_to_chat(t, chat_id, contact.id).await?;
|
||||
let sent_msg = t.pop_sent_msg().await;
|
||||
let (fp, recipient_fps) = t.parse_msg(&sent_msg).await.signature.unwrap();
|
||||
assert_eq!(fp, t_fp);
|
||||
// `mimefactory` encrypts to self unconditionally.
|
||||
assert_eq!(recipient_fps.len(), 1 + i + 1);
|
||||
assert!(recipient_fps.contains(&t_fp));
|
||||
assert!(recipient_fps.contains(&contact.fingerprint().unwrap()));
|
||||
}
|
||||
|
||||
t.set_config_bool(Config::BccSelf, true).await.unwrap();
|
||||
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
|
||||
let sent_msg = t.pop_sent_msg().await;
|
||||
let (fp, recipient_fps) = t.parse_msg(&sent_msg).await.signature.unwrap();
|
||||
assert_eq!(fp, t_fp);
|
||||
assert_eq!(recipient_fps.len(), 1 + members.len());
|
||||
assert!(recipient_fps.contains(&t_fp));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_long_in_reply_to() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
|
||||
@@ -148,6 +148,9 @@ pub enum Param {
|
||||
/// For Messages
|
||||
WebrtcAccepted = b'7',
|
||||
|
||||
/// For Messages
|
||||
WebrtcHasVideoInitially = b'z',
|
||||
|
||||
/// For Messages: space-separated list of messaged IDs of forwarded copies.
|
||||
///
|
||||
/// This is used when a [crate::message::Message] is in the
|
||||
|
||||
130
src/pgp.rs
130
src/pgp.rs
@@ -1,23 +1,24 @@
|
||||
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{BufRead, Cursor};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use chrono::SubsecRound;
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use pgp::armor::BlockType;
|
||||
use pgp::composed::{
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, KeyType as PgpKeyType,
|
||||
Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey,
|
||||
SignedSecretKey, SubkeyParamsBuilder, TheRing,
|
||||
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, EncryptionCaps,
|
||||
KeyType as PgpKeyType, Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey,
|
||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
|
||||
};
|
||||
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
|
||||
use pgp::crypto::ecc_curve::ECCCurve;
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{CompressionAlgorithm, KeyDetails, Password, PublicKeyTrait, StringToKey};
|
||||
use pgp::types::{
|
||||
CompressionAlgorithm, KeyDetails, KeyVersion, Password, SigningKey as _, StringToKey,
|
||||
};
|
||||
use rand_old::{Rng as _, thread_rng};
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
@@ -31,9 +32,6 @@ pub(crate) const HEADER_SETUPCODE: &str = "passphrase-begin";
|
||||
/// Preferred symmetric encryption algorithm.
|
||||
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
|
||||
|
||||
/// Preferred cryptographic hash.
|
||||
const HASH_ALGORITHM: HashAlgorithm = HashAlgorithm::Sha256;
|
||||
|
||||
/// Split data from PGP Armored Data as defined in <https://tools.ietf.org/html/rfc4880#section-6.2>.
|
||||
///
|
||||
/// Returns (type, headers, base64 encoded body).
|
||||
@@ -83,9 +81,7 @@ impl KeyPair {
|
||||
///
|
||||
/// Public key is split off the secret key.
|
||||
pub fn new(secret: SignedSecretKey) -> Result<Self> {
|
||||
use crate::key::DcSecretKey;
|
||||
|
||||
let public = secret.split_public_key()?;
|
||||
let public = secret.to_public_key();
|
||||
Ok(Self { public, secret })
|
||||
}
|
||||
}
|
||||
@@ -123,7 +119,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
.subkey(
|
||||
SubkeyParamsBuilder::default()
|
||||
.key_type(encryption_key_type)
|
||||
.can_encrypt(true)
|
||||
.can_encrypt(EncryptionCaps::All)
|
||||
.passphrase(None)
|
||||
.build()
|
||||
.context("failed to build subkey parameters")?,
|
||||
@@ -134,18 +130,16 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
let mut rng = thread_rng();
|
||||
let secret_key = key_params
|
||||
.generate(&mut rng)
|
||||
.context("failed to generate the key")?
|
||||
.sign(&mut rng, &Password::empty())
|
||||
.context("failed to sign secret key")?;
|
||||
.context("Failed to generate the key")?;
|
||||
secret_key
|
||||
.verify()
|
||||
.context("invalid secret key generated")?;
|
||||
.verify_bindings()
|
||||
.context("Invalid secret key generated")?;
|
||||
|
||||
let key_pair = KeyPair::new(secret_key)?;
|
||||
key_pair
|
||||
.public
|
||||
.verify()
|
||||
.context("invalid public key generated")?;
|
||||
.verify_bindings()
|
||||
.context("Invalid public key generated")?;
|
||||
Ok(key_pair)
|
||||
}
|
||||
|
||||
@@ -157,7 +151,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
|
||||
fn select_pk_for_encryption(key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
|
||||
key.public_subkeys
|
||||
.iter()
|
||||
.find(|subkey| subkey.is_encryption_key())
|
||||
.find(|subkey| subkey.algorithm().can_encrypt())
|
||||
}
|
||||
|
||||
/// Version of SEIPD packet to use.
|
||||
@@ -191,6 +185,36 @@ pub async fn pk_encrypt(
|
||||
let pkeys = public_keys_for_encryption
|
||||
.iter()
|
||||
.filter_map(select_pk_for_encryption);
|
||||
let subpkts = {
|
||||
let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1);
|
||||
hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime(
|
||||
pgp::types::Timestamp::now(),
|
||||
))?);
|
||||
// Test "elena" uses old Delta Chat.
|
||||
let skip = private_key_for_signing.dc_fingerprint().hex()
|
||||
== "B86586B6DEF437D674BFAFC02A6B2EBC633B9E82";
|
||||
for key in &public_keys_for_encryption {
|
||||
if skip {
|
||||
break;
|
||||
}
|
||||
let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint());
|
||||
let subpkt = match private_key_for_signing.version() < KeyVersion::V6 {
|
||||
true => Subpacket::regular(data)?,
|
||||
false => Subpacket::critical(data)?,
|
||||
};
|
||||
hashed.push(subpkt);
|
||||
}
|
||||
hashed.push(Subpacket::regular(SubpacketData::IssuerFingerprint(
|
||||
private_key_for_signing.fingerprint(),
|
||||
))?);
|
||||
let mut unhashed = vec![];
|
||||
if private_key_for_signing.version() <= KeyVersion::V4 {
|
||||
unhashed.push(Subpacket::regular(SubpacketData::IssuerKeyId(
|
||||
private_key_for_signing.legacy_key_id(),
|
||||
))?);
|
||||
}
|
||||
SubpacketConfig::UserDefined { hashed, unhashed }
|
||||
};
|
||||
|
||||
let msg = MessageBuilder::from_bytes("", plain);
|
||||
let encoded_msg = match seipd_version {
|
||||
@@ -205,7 +229,13 @@ pub async fn pk_encrypt(
|
||||
}
|
||||
}
|
||||
|
||||
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
|
||||
let hash_algorithm = private_key_for_signing.hash_alg();
|
||||
msg.sign_with_subpackets(
|
||||
&*private_key_for_signing,
|
||||
Password::empty(),
|
||||
hash_algorithm,
|
||||
subpkts,
|
||||
);
|
||||
if compress {
|
||||
msg.compression(CompressionAlgorithm::ZLIB);
|
||||
}
|
||||
@@ -228,7 +258,13 @@ pub async fn pk_encrypt(
|
||||
}
|
||||
}
|
||||
|
||||
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
|
||||
let hash_algorithm = private_key_for_signing.hash_alg();
|
||||
msg.sign_with_subpackets(
|
||||
&*private_key_for_signing,
|
||||
Password::empty(),
|
||||
hash_algorithm,
|
||||
subpkts,
|
||||
);
|
||||
if compress {
|
||||
msg.compression(CompressionAlgorithm::ZLIB);
|
||||
}
|
||||
@@ -260,12 +296,17 @@ pub fn pk_calc_signature(
|
||||
private_key_for_signing.fingerprint(),
|
||||
))?,
|
||||
Subpacket::critical(SubpacketData::SignatureCreationTime(
|
||||
chrono::Utc::now().trunc_subsecs(0),
|
||||
pgp::types::Timestamp::now(),
|
||||
))?,
|
||||
];
|
||||
config.unhashed_subpackets = vec![Subpacket::regular(SubpacketData::Issuer(
|
||||
private_key_for_signing.key_id(),
|
||||
))?];
|
||||
config.unhashed_subpackets = vec![];
|
||||
if private_key_for_signing.version() <= KeyVersion::V4 {
|
||||
config
|
||||
.unhashed_subpackets
|
||||
.push(Subpacket::regular(SubpacketData::IssuerKeyId(
|
||||
private_key_for_signing.legacy_key_id(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let signature = config.sign(
|
||||
&private_key_for_signing.primary_key,
|
||||
@@ -369,19 +410,28 @@ fn check_symmetric_encryption(msg: &Message<'_>) -> std::result::Result<(), &'st
|
||||
|
||||
/// Returns fingerprints
|
||||
/// of all keys from the `public_keys_for_validation` keyring that
|
||||
/// have valid signatures there.
|
||||
/// have valid signatures in `msg` and corresponding intended recipient fingerprints
|
||||
/// (<https://www.rfc-editor.org/rfc/rfc9580.html#name-intended-recipient-fingerpr>) if any.
|
||||
///
|
||||
/// If the message is wrongly signed, HashSet will be empty.
|
||||
/// If the message is wrongly signed, returns an empty map.
|
||||
pub fn valid_signature_fingerprints(
|
||||
msg: &pgp::composed::Message,
|
||||
public_keys_for_validation: &[SignedPublicKey],
|
||||
) -> HashSet<Fingerprint> {
|
||||
let mut ret_signature_fingerprints: HashSet<Fingerprint> = Default::default();
|
||||
) -> HashMap<Fingerprint, Vec<Fingerprint>> {
|
||||
let mut ret_signature_fingerprints = HashMap::new();
|
||||
if msg.is_signed() {
|
||||
for pkey in public_keys_for_validation {
|
||||
if msg.verify(&pkey.primary_key).is_ok() {
|
||||
if let Ok(signature) = msg.verify(&pkey.primary_key) {
|
||||
let fp = pkey.dc_fingerprint();
|
||||
ret_signature_fingerprints.insert(fp);
|
||||
let mut recipient_fps = Vec::new();
|
||||
if let Some(cfg) = signature.config() {
|
||||
for subpkt in &cfg.hashed_subpackets {
|
||||
if let SubpacketData::IntendedRecipientFingerprint(fp) = &subpkt.data {
|
||||
recipient_fps.push(fp.clone().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
ret_signature_fingerprints.insert(fp, recipient_fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,7 +503,8 @@ pub async fn symm_encrypt_message(
|
||||
);
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?;
|
||||
|
||||
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
|
||||
let hash_algorithm = private_key_for_signing.hash_alg();
|
||||
msg.sign(&*private_key_for_signing, Password::empty(), hash_algorithm);
|
||||
if compress {
|
||||
msg.compression(CompressionAlgorithm::ZLIB);
|
||||
}
|
||||
@@ -495,13 +546,14 @@ mod tests {
|
||||
use pgp::composed::Esk;
|
||||
use pgp::packet::PublicKeyEncryptedSessionKey;
|
||||
|
||||
#[expect(clippy::type_complexity)]
|
||||
fn pk_decrypt_and_validate<'a>(
|
||||
ctext: &'a [u8],
|
||||
private_keys_for_decryption: &'a [SignedSecretKey],
|
||||
public_keys_for_validation: &[SignedPublicKey],
|
||||
) -> Result<(
|
||||
pgp::composed::Message<'static>,
|
||||
HashSet<Fingerprint>,
|
||||
HashMap<Fingerprint, Vec<Fingerprint>>,
|
||||
Vec<u8>,
|
||||
)> {
|
||||
let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?;
|
||||
@@ -609,7 +661,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decrypt_singed() {
|
||||
async fn test_decrypt_signed() {
|
||||
// Check decrypting as Alice
|
||||
let decrypt_keyring = vec![KEYS.alice_secret.clone()];
|
||||
let sig_check_keyring = vec![KEYS.alice_public.clone()];
|
||||
@@ -621,6 +673,9 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 1);
|
||||
for recipient_fps in valid_signatures.values() {
|
||||
assert_eq!(recipient_fps.len(), 2);
|
||||
}
|
||||
|
||||
// Check decrypting as Bob
|
||||
let decrypt_keyring = vec![KEYS.bob_secret.clone()];
|
||||
@@ -633,6 +688,9 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 1);
|
||||
for recipient_fps in valid_signatures.values() {
|
||||
assert_eq!(recipient_fps.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -145,6 +145,7 @@ pub struct Provider {
|
||||
|
||||
/// Provider options with good defaults.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct ProviderOptions {
|
||||
/// True if provider is known to use use proper,
|
||||
/// not self-signed certificates.
|
||||
@@ -152,9 +153,6 @@ pub struct ProviderOptions {
|
||||
|
||||
/// Maximum number of recipients the provider allows to send a single email to.
|
||||
pub max_smtp_rcpt_to: Option<u16>,
|
||||
|
||||
/// Move messages to the Trash folder instead of marking them "\Deleted".
|
||||
pub delete_to_trash: bool,
|
||||
}
|
||||
|
||||
impl ProviderOptions {
|
||||
@@ -162,7 +160,6 @@ impl ProviderOptions {
|
||||
Self {
|
||||
strict_tls: true,
|
||||
max_smtp_rcpt_to: None,
|
||||
delete_to_trash: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,16 +505,10 @@ static P_FIVE_CHAT: Provider = Provider {
|
||||
overview_page: "https://providers.delta.chat/five-chat",
|
||||
server: &[],
|
||||
opt: ProviderOptions::new(),
|
||||
config_defaults: Some(&[
|
||||
ConfigDefault {
|
||||
key: Config::BccSelf,
|
||||
value: "1",
|
||||
},
|
||||
ConfigDefault {
|
||||
key: Config::MvboxMove,
|
||||
value: "0",
|
||||
},
|
||||
]),
|
||||
config_defaults: Some(&[ConfigDefault {
|
||||
key: Config::BccSelf,
|
||||
value: "1",
|
||||
}]),
|
||||
oauth2_authorizer: None,
|
||||
};
|
||||
|
||||
@@ -564,7 +558,7 @@ static P_FREENET_DE: Provider = Provider {
|
||||
static P_GMAIL: Provider = Provider {
|
||||
id: "gmail",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "For Gmail accounts, you need to have \"2-Step Verification\" enabled and create an app-password.",
|
||||
before_login_hint: "For Gmail accounts, you need to have \"2-Step Verification\" enabled and create an app-password. Gmail limits how many messages you can send per day.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/gmail",
|
||||
server: &[
|
||||
@@ -583,10 +577,7 @@ static P_GMAIL: Provider = Provider {
|
||||
username_pattern: Email,
|
||||
},
|
||||
],
|
||||
opt: ProviderOptions {
|
||||
delete_to_trash: true,
|
||||
..ProviderOptions::new()
|
||||
},
|
||||
opt: ProviderOptions::new(),
|
||||
config_defaults: None,
|
||||
oauth2_authorizer: None,
|
||||
};
|
||||
@@ -1080,10 +1071,6 @@ static P_NAUTA_CU: Provider = Provider {
|
||||
key: Config::DeleteServerAfter,
|
||||
value: "1",
|
||||
},
|
||||
ConfigDefault {
|
||||
key: Config::MvboxMove,
|
||||
value: "0",
|
||||
},
|
||||
ConfigDefault {
|
||||
key: Config::MediaQuality,
|
||||
value: "1",
|
||||
@@ -1172,10 +1159,7 @@ static P_NINE_TESTRUN_ORG: Provider = Provider {
|
||||
},
|
||||
],
|
||||
opt: ProviderOptions::new(),
|
||||
config_defaults: Some(&[ConfigDefault {
|
||||
key: Config::MvboxMove,
|
||||
value: "0",
|
||||
}]),
|
||||
config_defaults: None,
|
||||
oauth2_authorizer: None,
|
||||
};
|
||||
|
||||
@@ -1616,16 +1600,10 @@ static P_TESTRUN: Provider = Provider {
|
||||
},
|
||||
],
|
||||
opt: ProviderOptions::new(),
|
||||
config_defaults: Some(&[
|
||||
ConfigDefault {
|
||||
key: Config::BccSelf,
|
||||
value: "1",
|
||||
},
|
||||
ConfigDefault {
|
||||
key: Config::MvboxMove,
|
||||
value: "0",
|
||||
},
|
||||
]),
|
||||
config_defaults: Some(&[ConfigDefault {
|
||||
key: Config::BccSelf,
|
||||
value: "1",
|
||||
}]),
|
||||
oauth2_authorizer: None,
|
||||
};
|
||||
|
||||
@@ -1966,10 +1944,7 @@ static P_YGGMAIL: Provider = Provider {
|
||||
},
|
||||
],
|
||||
opt: ProviderOptions::new(),
|
||||
config_defaults: Some(&[ConfigDefault {
|
||||
key: Config::MvboxMove,
|
||||
value: "0",
|
||||
}]),
|
||||
config_defaults: None,
|
||||
oauth2_authorizer: None,
|
||||
};
|
||||
|
||||
@@ -2647,4 +2622,4 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
|
||||
});
|
||||
|
||||
pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> =
|
||||
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2025, 9, 10).unwrap());
|
||||
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2026, 1, 28).unwrap());
|
||||
|
||||
@@ -59,14 +59,12 @@ pub enum LoginOptions {
|
||||
/// scheme: `dclogin://user@host/?p=password&v=1[&options]`
|
||||
/// read more about the scheme at <https://github.com/deltachat/interface/blob/master/uri-schemes.md#DCLOGIN>
|
||||
pub(super) fn decode_login(qr: &str) -> Result<Qr> {
|
||||
let url = url::Url::parse(qr).with_context(|| format!("Malformed url: {qr:?}"))?;
|
||||
let qr = qr.replacen("://", ":", 1);
|
||||
|
||||
let url_without_scheme = qr
|
||||
let url = url::Url::parse(&qr).with_context(|| format!("Malformed url: {qr:?}"))?;
|
||||
let payload = qr
|
||||
.get(DCLOGIN_SCHEME.len()..)
|
||||
.context("invalid DCLOGIN payload E1")?;
|
||||
let payload = url_without_scheme
|
||||
.strip_prefix("//")
|
||||
.unwrap_or(url_without_scheme);
|
||||
|
||||
let addr = payload
|
||||
.split(['?', '/'])
|
||||
@@ -365,4 +363,32 @@ mod test {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_dclogin_ipv4() -> anyhow::Result<()> {
|
||||
let result = decode_login("dclogin://test@[127.0.0.1]?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(address, "test@[127.0.0.1]".to_owned());
|
||||
assert_eq!(options, login_options_just_pw!("1234".to_owned()));
|
||||
} else {
|
||||
unreachable!("wrong type");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_dclogin_ipv6() -> anyhow::Result<()> {
|
||||
let result =
|
||||
decode_login("dclogin://test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]?p=1234&v=1")?;
|
||||
if let Qr::Login { address, options } = result {
|
||||
assert_eq!(
|
||||
address,
|
||||
"test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]".to_owned()
|
||||
);
|
||||
assert_eq!(options, login_options_just_pw!("1234".to_owned()));
|
||||
} else {
|
||||
unreachable!("wrong type");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use async_imap::types::{Quota, QuotaResource};
|
||||
use crate::chat::add_device_msg_with_importance;
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::imap::scan_folders::get_watched_folders;
|
||||
use crate::imap::get_watched_folders;
|
||||
use crate::imap::session::Session as ImapSession;
|
||||
use crate::log::warn;
|
||||
use crate::message::Message;
|
||||
|
||||
@@ -747,13 +747,23 @@ Content-Disposition: reaction\n\
|
||||
alice_reaction_msg.id.get_state(&alice).await?,
|
||||
MessageState::InSeen
|
||||
);
|
||||
// Reactions don't request MDNs.
|
||||
// Reactions don't request MDNs, but an MDN to self is sent.
|
||||
assert_eq!(
|
||||
alice
|
||||
.sql
|
||||
.count("SELECT COUNT(*) FROM smtp_mdns", ())
|
||||
.await?,
|
||||
0
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
alice
|
||||
.sql
|
||||
.count(
|
||||
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id=?",
|
||||
(ContactId::SELF,)
|
||||
)
|
||||
.await?,
|
||||
1
|
||||
);
|
||||
|
||||
// Alice reacts to own message.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Internet Message Format reception pipeline.
|
||||
|
||||
use std::cmp;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::iter;
|
||||
use std::sync::LazyLock;
|
||||
@@ -14,7 +15,7 @@ use mailparse::SingleInfo;
|
||||
use num_traits::FromPrimitive;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, save_broadcast_secret};
|
||||
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ChatVisibility, save_broadcast_secret};
|
||||
use crate::config::Config;
|
||||
use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
|
||||
use crate::contact::{self, Contact, ContactId, Origin, mark_contact_id_as_verified};
|
||||
@@ -26,7 +27,9 @@ use crate::events::EventType;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table};
|
||||
use crate::key::{DcKey, Fingerprint};
|
||||
use crate::key::{self_fingerprint, self_fingerprint_opt};
|
||||
use crate::key::{
|
||||
load_self_public_key, load_self_public_key_opt, self_fingerprint, self_fingerprint_opt,
|
||||
};
|
||||
use crate::log::{LogExt as _, warn};
|
||||
use crate::message::{
|
||||
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
|
||||
@@ -195,7 +198,7 @@ async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId>
|
||||
async fn get_to_and_past_contact_ids(
|
||||
context: &Context,
|
||||
mime_parser: &MimeMessage,
|
||||
chat_assignment: &ChatAssignment,
|
||||
chat_assignment: &mut ChatAssignment,
|
||||
parent_message: &Option<Message>,
|
||||
incoming_origin: Origin,
|
||||
) -> Result<(Vec<Option<ContactId>>, Vec<Option<ContactId>>)> {
|
||||
@@ -281,7 +284,7 @@ async fn get_to_and_past_contact_ids(
|
||||
.await?;
|
||||
|
||||
if let Some(chat_id) = chat_id {
|
||||
past_ids = lookup_key_contacts_by_address_list(
|
||||
past_ids = lookup_key_contacts_fallback_to_chat(
|
||||
context,
|
||||
&mime_parser.past_members,
|
||||
past_member_fingerprints,
|
||||
@@ -314,7 +317,7 @@ async fn get_to_and_past_contact_ids(
|
||||
Origin::Hidden,
|
||||
)
|
||||
.await?;
|
||||
past_ids = lookup_key_contacts_by_address_list(
|
||||
past_ids = lookup_key_contacts_fallback_to_chat(
|
||||
context,
|
||||
&mime_parser.past_members,
|
||||
past_member_fingerprints,
|
||||
@@ -384,54 +387,58 @@ async fn get_to_and_past_contact_ids(
|
||||
// mapped it to a key contact.
|
||||
// This is an encrypted 1:1 chat.
|
||||
to_ids = pgp_to_ids
|
||||
} else if let Some(chat_id) = chat_id {
|
||||
to_ids = match mime_parser.was_encrypted() {
|
||||
true => {
|
||||
lookup_key_contacts_by_address_list(
|
||||
context,
|
||||
&mime_parser.recipients,
|
||||
to_member_fingerprints,
|
||||
Some(chat_id),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
false => {
|
||||
add_or_lookup_contacts_by_address_list(
|
||||
context,
|
||||
&mime_parser.recipients,
|
||||
if !mime_parser.incoming {
|
||||
Origin::OutgoingTo
|
||||
} else if incoming_origin.is_known() {
|
||||
Origin::IncomingTo
|
||||
} else {
|
||||
Origin::IncomingUnknownTo
|
||||
},
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let ids = match mime_parser.was_encrypted() {
|
||||
true => {
|
||||
lookup_key_contacts_by_address_list(
|
||||
let ids = if mime_parser.was_encrypted() {
|
||||
let mut recipient_fps = mime_parser
|
||||
.signature
|
||||
.as_ref()
|
||||
.map(|(_, recipient_fps)| recipient_fps.iter().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
// If there are extra recipient fingerprints, it may be a non-chat "implicit
|
||||
// Bcc" message. Fall back to in-chat lookup if so.
|
||||
if !recipient_fps.is_empty() && recipient_fps.len() <= 2 {
|
||||
let self_fp = load_self_public_key(context).await?.dc_fingerprint();
|
||||
recipient_fps.retain(|fp| *fp != self_fp);
|
||||
if recipient_fps.is_empty() {
|
||||
vec![Some(ContactId::SELF)]
|
||||
} else {
|
||||
add_or_lookup_key_contacts(
|
||||
context,
|
||||
&mime_parser.recipients,
|
||||
&mime_parser.gossiped_keys,
|
||||
&recipient_fps,
|
||||
Origin::Hidden,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
} else {
|
||||
lookup_key_contacts_fallback_to_chat(
|
||||
context,
|
||||
&mime_parser.recipients,
|
||||
to_member_fingerprints,
|
||||
None,
|
||||
chat_id,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
false => vec![],
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
if mime_parser.was_encrypted() && !ids.contains(&None)
|
||||
// Prefer creating PGP chats if there are any key-contacts. At least this prevents
|
||||
// from replying unencrypted.
|
||||
// from replying unencrypted. Otherwise downgrade to a non-replyable ad-hoc group.
|
||||
|| ids
|
||||
.iter()
|
||||
.any(|&c| c.is_some() && c != Some(ContactId::SELF))
|
||||
{
|
||||
to_ids = ids;
|
||||
} else {
|
||||
if mime_parser.was_encrypted() {
|
||||
warn!(
|
||||
context,
|
||||
"No key-contact looked up. Downgrading to AdHocGroup."
|
||||
);
|
||||
*chat_assignment = ChatAssignment::AdHocGroup;
|
||||
}
|
||||
to_ids = add_or_lookup_contacts_by_address_list(
|
||||
context,
|
||||
&mime_parser.recipients,
|
||||
@@ -487,6 +494,18 @@ pub(crate) async fn receive_imf_inner(
|
||||
);
|
||||
}
|
||||
|
||||
let trash = || async {
|
||||
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
|
||||
Ok(Some(ReceivedMsg {
|
||||
chat_id: DC_CHAT_ID_TRASH,
|
||||
state: MessageState::Undefined,
|
||||
hidden: false,
|
||||
sort_timestamp: 0,
|
||||
msg_ids,
|
||||
needs_delete_job: false,
|
||||
}))
|
||||
};
|
||||
|
||||
let mut mime_parser = match MimeMessage::from_bytes(context, imf_raw).await {
|
||||
Err(err) => {
|
||||
warn!(context, "receive_imf: can't parse MIME: {err:#}.");
|
||||
@@ -494,17 +513,7 @@ pub(crate) async fn receive_imf_inner(
|
||||
// We don't have an rfc724_mid, there's no point in adding a trash entry
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
|
||||
|
||||
return Ok(Some(ReceivedMsg {
|
||||
chat_id: DC_CHAT_ID_TRASH,
|
||||
state: MessageState::Undefined,
|
||||
hidden: false,
|
||||
sort_timestamp: 0,
|
||||
msg_ids,
|
||||
needs_delete_job: false,
|
||||
}));
|
||||
return trash().await;
|
||||
}
|
||||
Ok(mime_parser) => mime_parser,
|
||||
};
|
||||
@@ -512,6 +521,18 @@ pub(crate) async fn receive_imf_inner(
|
||||
let rfc724_mid_orig = &mime_parser
|
||||
.get_rfc724_mid()
|
||||
.unwrap_or(rfc724_mid.to_string());
|
||||
|
||||
if let Some((_, recipient_fps)) = &mime_parser.signature
|
||||
&& !recipient_fps.is_empty()
|
||||
&& let Some(self_pubkey) = load_self_public_key_opt(context).await?
|
||||
&& !recipient_fps.contains(&self_pubkey.dc_fingerprint())
|
||||
{
|
||||
warn!(
|
||||
context,
|
||||
"Message {rfc724_mid_orig:?} is not intended for us (TRASH)."
|
||||
);
|
||||
return trash().await;
|
||||
}
|
||||
info!(
|
||||
context,
|
||||
"Receiving message {rfc724_mid_orig:?}, seen={seen}...",
|
||||
@@ -595,7 +616,7 @@ pub(crate) async fn receive_imf_inner(
|
||||
// For example, GitHub sends messages from `notifications@github.com`,
|
||||
// but uses display name of the user whose action generated the notification
|
||||
// as the display name.
|
||||
let fingerprint = mime_parser.signature.as_ref();
|
||||
let fingerprint = mime_parser.signature.as_ref().map(|(fp, _)| fp);
|
||||
let (from_id, _from_id_blocked, incoming_origin) = match from_field_to_contact_id(
|
||||
context,
|
||||
&mime_parser.from,
|
||||
@@ -633,14 +654,12 @@ pub(crate) async fn receive_imf_inner(
|
||||
.await?
|
||||
.filter(|p| Some(p.id) != replace_msg_id);
|
||||
|
||||
let chat_assignment =
|
||||
let mut chat_assignment =
|
||||
decide_chat_assignment(context, &mime_parser, &parent_message, rfc724_mid, from_id).await?;
|
||||
info!(context, "Chat assignment is {chat_assignment:?}.");
|
||||
|
||||
let (to_ids, past_ids) = get_to_and_past_contact_ids(
|
||||
context,
|
||||
&mime_parser,
|
||||
&chat_assignment,
|
||||
&mut chat_assignment,
|
||||
&parent_message,
|
||||
incoming_origin,
|
||||
)
|
||||
@@ -933,7 +952,7 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
|
||||
|
||||
if !received_msg.msg_ids.is_empty() {
|
||||
let target = if received_msg.needs_delete_job || delete_server_after == Some(0) {
|
||||
Some(context.get_delete_msgs_target().await?)
|
||||
Some("".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -960,6 +979,74 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
|
||||
// This is a Delta Chat MDN. Mark as read.
|
||||
markseen_on_imap_table(context, rfc724_mid_orig).await?;
|
||||
}
|
||||
if !mime_parser.incoming && !context.get_config_bool(Config::TeamProfile).await? {
|
||||
let mut updated_chats = BTreeMap::new();
|
||||
let mut archived_chats_maybe_noticed = false;
|
||||
for report in &mime_parser.mdn_reports {
|
||||
for msg_rfc724_mid in report
|
||||
.original_message_id
|
||||
.iter()
|
||||
.chain(&report.additional_message_ids)
|
||||
{
|
||||
let Some(msg_id) = rfc724_mid_exists(context, msg_rfc724_mid).await? else {
|
||||
continue;
|
||||
};
|
||||
let Some(msg) = Message::load_from_db_optional(context, msg_id).await? else {
|
||||
continue;
|
||||
};
|
||||
if msg.state < MessageState::InFresh || msg.state >= MessageState::InSeen {
|
||||
continue;
|
||||
}
|
||||
if !mime_parser.was_encrypted() && msg.get_showpadlock() {
|
||||
warn!(context, "MDN: Not encrypted. Ignoring.");
|
||||
continue;
|
||||
}
|
||||
message::update_msg_state(context, msg_id, MessageState::InSeen).await?;
|
||||
if let Err(e) = msg_id.start_ephemeral_timer(context).await {
|
||||
error!(context, "start_ephemeral_timer for {msg_id}: {e:#}.");
|
||||
}
|
||||
if !mime_parser.has_chat_version() {
|
||||
continue;
|
||||
}
|
||||
archived_chats_maybe_noticed |= msg.state < MessageState::InNoticed
|
||||
&& msg.chat_visibility == ChatVisibility::Archived;
|
||||
updated_chats
|
||||
.entry(msg.chat_id)
|
||||
.and_modify(|ts| *ts = cmp::max(*ts, msg.timestamp_sort))
|
||||
.or_insert(msg.timestamp_sort);
|
||||
}
|
||||
}
|
||||
for (chat_id, timestamp_sort) in updated_chats {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"
|
||||
UPDATE msgs SET state=? WHERE
|
||||
state=? AND
|
||||
hidden=0 AND
|
||||
chat_id=? AND
|
||||
timestamp<?",
|
||||
(
|
||||
MessageState::InNoticed,
|
||||
MessageState::InFresh,
|
||||
chat_id,
|
||||
timestamp_sort,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.context("UPDATE msgs.state")?;
|
||||
if chat_id.get_fresh_msg_cnt(context).await? == 0 {
|
||||
// Removes all notifications for the chat in UIs.
|
||||
context.emit_event(EventType::MsgsNoticed(chat_id));
|
||||
} else {
|
||||
context.emit_msgs_changed_without_msg_id(chat_id);
|
||||
}
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat_id);
|
||||
}
|
||||
if archived_chats_maybe_noticed {
|
||||
context.on_archived_chats_maybe_noticed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mime_parser.is_call() {
|
||||
@@ -1244,17 +1331,34 @@ async fn decide_chat_assignment(
|
||||
if from_id != ContactId::SELF && !has_self_addr {
|
||||
num_recipients += 1;
|
||||
}
|
||||
let can_be_11_chat = num_recipients <= 1
|
||||
&& (from_id != ContactId::SELF
|
||||
|| !(mime_parser.recipients.is_empty() || has_self_addr)
|
||||
|| mime_parser.was_encrypted());
|
||||
let mut can_be_11_chat_log = String::new();
|
||||
let mut l = |cond: bool, s: String| {
|
||||
can_be_11_chat_log += &s;
|
||||
cond
|
||||
};
|
||||
let can_be_11_chat = l(
|
||||
num_recipients <= 1,
|
||||
format!("num_recipients={num_recipients}."),
|
||||
) && (l(from_id != ContactId::SELF, format!(" from_id={from_id}."))
|
||||
|| !(l(
|
||||
mime_parser.recipients.is_empty(),
|
||||
format!(" Raw recipients len={}.", mime_parser.recipients.len()),
|
||||
) || l(has_self_addr, format!(" has_self_addr={has_self_addr}.")))
|
||||
|| l(
|
||||
mime_parser.was_encrypted(),
|
||||
format!(" was_encrypted={}.", mime_parser.was_encrypted()),
|
||||
));
|
||||
|
||||
let chat_assignment_log;
|
||||
let chat_assignment = if should_trash {
|
||||
chat_assignment_log = "".to_string();
|
||||
ChatAssignment::Trash
|
||||
} else if mime_parser.get_mailinglist_header().is_some() {
|
||||
chat_assignment_log = "Mailing list header found.".to_string();
|
||||
ChatAssignment::MailingListOrBroadcast
|
||||
} else if let Some(grpid) = mime_parser.get_chat_group_id() {
|
||||
if mime_parser.was_encrypted() {
|
||||
chat_assignment_log = "Encrypted group message.".to_string();
|
||||
ChatAssignment::GroupChat {
|
||||
grpid: grpid.to_string(),
|
||||
}
|
||||
@@ -1263,11 +1367,13 @@ async fn decide_chat_assignment(
|
||||
lookup_chat_by_reply(context, mime_parser, parent).await?
|
||||
{
|
||||
// Try to assign to a chat based on In-Reply-To/References.
|
||||
chat_assignment_log = "Unencrypted group reply.".to_string();
|
||||
ChatAssignment::ExistingChat {
|
||||
chat_id,
|
||||
chat_id_blocked,
|
||||
}
|
||||
} else {
|
||||
chat_assignment_log = "Unencrypted group reply.".to_string();
|
||||
ChatAssignment::AdHocGroup
|
||||
}
|
||||
} else {
|
||||
@@ -1278,6 +1384,7 @@ async fn decide_chat_assignment(
|
||||
// even if it had only two members.
|
||||
//
|
||||
// Group ID is ignored, however.
|
||||
chat_assignment_log = "Unencrypted group message, no parent.".to_string();
|
||||
ChatAssignment::AdHocGroup
|
||||
}
|
||||
} else if let Some(parent) = &parent_message {
|
||||
@@ -1285,24 +1392,38 @@ async fn decide_chat_assignment(
|
||||
lookup_chat_by_reply(context, mime_parser, parent).await?
|
||||
{
|
||||
// Try to assign to a chat based on In-Reply-To/References.
|
||||
chat_assignment_log = "Reply w/o grpid.".to_string();
|
||||
ChatAssignment::ExistingChat {
|
||||
chat_id,
|
||||
chat_id_blocked,
|
||||
}
|
||||
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
|
||||
chat_assignment_log = "Reply with Chat-Group-Name.".to_string();
|
||||
ChatAssignment::AdHocGroup
|
||||
} else if can_be_11_chat {
|
||||
chat_assignment_log = format!("Non-group reply. {can_be_11_chat_log}");
|
||||
ChatAssignment::OneOneChat
|
||||
} else {
|
||||
chat_assignment_log = format!("Non-group reply. {can_be_11_chat_log}");
|
||||
ChatAssignment::AdHocGroup
|
||||
}
|
||||
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
|
||||
chat_assignment_log = "Message with Chat-Group-Name, no parent.".to_string();
|
||||
ChatAssignment::AdHocGroup
|
||||
} else if can_be_11_chat {
|
||||
chat_assignment_log = format!("Non-group message, no parent. {can_be_11_chat_log}");
|
||||
ChatAssignment::OneOneChat
|
||||
} else {
|
||||
chat_assignment_log = format!("Non-group message, no parent. {can_be_11_chat_log}");
|
||||
ChatAssignment::AdHocGroup
|
||||
};
|
||||
|
||||
if !chat_assignment_log.is_empty() {
|
||||
info!(
|
||||
context,
|
||||
"{chat_assignment_log} Chat assignment = {chat_assignment:?}."
|
||||
);
|
||||
}
|
||||
Ok(chat_assignment)
|
||||
}
|
||||
|
||||
@@ -1696,8 +1817,6 @@ async fn add_parts(
|
||||
}
|
||||
|
||||
let is_location_kml = mime_parser.location_kml.is_some();
|
||||
let is_mdn = !mime_parser.mdn_reports.is_empty();
|
||||
|
||||
let mut group_changes = match chat.typ {
|
||||
_ if chat.id.is_special() => GroupChangesInfo::default(),
|
||||
Chattype::Single => GroupChangesInfo::default(),
|
||||
@@ -1733,7 +1852,10 @@ async fn add_parts(
|
||||
|
||||
let state = if !mime_parser.incoming {
|
||||
MessageState::OutDelivered
|
||||
} else if seen || is_mdn || chat_id_blocked == Blocked::Yes || group_changes.silent
|
||||
} else if seen
|
||||
|| !mime_parser.mdn_reports.is_empty()
|
||||
|| chat_id_blocked == Blocked::Yes
|
||||
|| group_changes.silent
|
||||
// No check for `hidden` because only reactions are such and they should be `InFresh`.
|
||||
{
|
||||
MessageState::InSeen
|
||||
@@ -2251,19 +2373,13 @@ RETURNING id
|
||||
}
|
||||
}
|
||||
|
||||
// Normally outgoing MDNs sent by us never appear in mailboxes, but Gmail saves all
|
||||
// outgoing messages, including MDNs, to the Sent folder. If we detect such saved MDN,
|
||||
// delete it.
|
||||
let needs_delete_job =
|
||||
!mime_parser.incoming && is_mdn && is_dc_message == MessengerMessage::Yes;
|
||||
|
||||
Ok(ReceivedMsg {
|
||||
chat_id,
|
||||
state,
|
||||
hidden,
|
||||
sort_timestamp,
|
||||
msg_ids: created_db_entries,
|
||||
needs_delete_job,
|
||||
needs_delete_job: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2602,6 +2718,9 @@ async fn lookup_or_create_adhoc_group(
|
||||
let to_ids: Vec<ContactId> = to_ids.iter().filter_map(|x| *x).collect();
|
||||
let mut contact_ids = BTreeSet::<ContactId>::from_iter(to_ids.iter().copied());
|
||||
contact_ids.insert(from_id);
|
||||
if mime_parser.was_encrypted() {
|
||||
contact_ids.remove(&ContactId::SELF);
|
||||
}
|
||||
let trans_fn = |t: &mut rusqlite::Transaction| {
|
||||
t.pragma_update(None, "query_only", "0")?;
|
||||
t.execute(
|
||||
@@ -2909,6 +3028,8 @@ async fn apply_group_changes(
|
||||
to_ids: &[Option<ContactId>],
|
||||
past_ids: &[Option<ContactId>],
|
||||
) -> Result<GroupChangesInfo> {
|
||||
let from_is_key_contact = Contact::get_by_id(context, from_id).await?.is_key_contact();
|
||||
ensure!(from_is_key_contact || chat.grpid.is_empty());
|
||||
let to_ids_flat: Vec<ContactId> = to_ids.iter().filter_map(|x| *x).collect();
|
||||
ensure!(chat.typ == Chattype::Group);
|
||||
ensure!(!chat.id.is_special());
|
||||
@@ -2990,7 +3111,10 @@ async fn apply_group_changes(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if chat.member_list_is_stale(context).await? {
|
||||
// Avoid insertion of `from_id` into a group with inappropriate encryption state.
|
||||
if from_is_key_contact != chat.grpid.is_empty()
|
||||
&& chat.member_list_is_stale(context).await?
|
||||
{
|
||||
info!(context, "Member list is stale.");
|
||||
let mut new_members: HashSet<ContactId> =
|
||||
HashSet::from_iter(to_ids_flat.iter().copied());
|
||||
@@ -2998,7 +3122,9 @@ async fn apply_group_changes(
|
||||
if !from_id.is_special() {
|
||||
new_members.insert(from_id);
|
||||
}
|
||||
|
||||
if mime_parser.was_encrypted() && chat.grpid.is_empty() {
|
||||
new_members.remove(&ContactId::SELF);
|
||||
}
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
@@ -3048,7 +3174,7 @@ async fn apply_group_changes(
|
||||
if self_added {
|
||||
new_members = HashSet::from_iter(to_ids_flat.iter().copied());
|
||||
new_members.insert(ContactId::SELF);
|
||||
if !from_id.is_special() {
|
||||
if !from_id.is_special() && from_is_key_contact != chat.grpid.is_empty() {
|
||||
new_members.insert(from_id);
|
||||
}
|
||||
} else {
|
||||
@@ -3072,6 +3198,10 @@ async fn apply_group_changes(
|
||||
new_members.remove(&removed_id);
|
||||
}
|
||||
|
||||
if mime_parser.was_encrypted() && chat.grpid.is_empty() {
|
||||
new_members.remove(&ContactId::SELF);
|
||||
}
|
||||
|
||||
if new_members != chat_contacts {
|
||||
chat::update_chat_contacts_table(
|
||||
context,
|
||||
@@ -3713,11 +3843,15 @@ async fn create_adhoc_group(
|
||||
to_ids: &[ContactId],
|
||||
grpname: &str,
|
||||
) -> Result<Option<(ChatId, Blocked)>> {
|
||||
let mut member_ids: Vec<ContactId> = to_ids.to_vec();
|
||||
if !member_ids.contains(&(from_id)) {
|
||||
let mut member_ids: Vec<ContactId> = to_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| id != ContactId::SELF)
|
||||
.collect();
|
||||
if from_id != ContactId::SELF && !member_ids.contains(&from_id) {
|
||||
member_ids.push(from_id);
|
||||
}
|
||||
if !member_ids.contains(&(ContactId::SELF)) {
|
||||
if !mime_parser.was_encrypted() {
|
||||
member_ids.push(ContactId::SELF);
|
||||
}
|
||||
|
||||
@@ -3806,7 +3940,7 @@ async fn has_verified_encryption(
|
||||
let signed_with_verified_key = mimeparser
|
||||
.signature
|
||||
.as_ref()
|
||||
.is_some_and(|signature| *signature == fingerprint);
|
||||
.is_some_and(|(signature, _)| *signature == fingerprint);
|
||||
if signed_with_verified_key {
|
||||
Ok(Verified)
|
||||
} else {
|
||||
@@ -3934,12 +4068,13 @@ async fn add_or_lookup_key_contacts(
|
||||
let mut contact_ids = Vec::new();
|
||||
let mut fingerprint_iter = fingerprints.iter();
|
||||
for info in address_list {
|
||||
let fp = fingerprint_iter.next();
|
||||
let addr = &info.addr;
|
||||
if !may_be_valid_addr(addr) {
|
||||
contact_ids.push(None);
|
||||
continue;
|
||||
}
|
||||
let fingerprint: String = if let Some(fp) = fingerprint_iter.next() {
|
||||
let fingerprint: String = if let Some(fp) = fp {
|
||||
// Iterator has not ran out of fingerprints yet.
|
||||
fp.hex()
|
||||
} else if let Some(key) = gossiped_keys.get(addr) {
|
||||
@@ -4090,7 +4225,7 @@ async fn lookup_key_contact_by_fingerprint(
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up key-contacts by email addresses.
|
||||
/// Adds or looks up key-contacts by fingerprints or by email addresses in the given chat.
|
||||
///
|
||||
/// `fingerprints` may be empty.
|
||||
/// This is used as a fallback when email addresses are available,
|
||||
@@ -4105,7 +4240,7 @@ async fn lookup_key_contact_by_fingerprint(
|
||||
/// is the same as the number of addresses in the header
|
||||
/// and it is possible to find corresponding
|
||||
/// `Chat-Group-Member-Timestamps` items.
|
||||
async fn lookup_key_contacts_by_address_list(
|
||||
async fn lookup_key_contacts_fallback_to_chat(
|
||||
context: &Context,
|
||||
address_list: &[SingleInfo],
|
||||
fingerprints: &[Fingerprint],
|
||||
@@ -4114,13 +4249,14 @@ async fn lookup_key_contacts_by_address_list(
|
||||
let mut contact_ids = Vec::new();
|
||||
let mut fingerprint_iter = fingerprints.iter();
|
||||
for info in address_list {
|
||||
let fp = fingerprint_iter.next();
|
||||
let addr = &info.addr;
|
||||
if !may_be_valid_addr(addr) {
|
||||
contact_ids.push(None);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(fp) = fingerprint_iter.next() {
|
||||
if let Some(fp) = fp {
|
||||
// Iterator has not ran out of fingerprints yet.
|
||||
let display_name = info.display_name.as_deref();
|
||||
let fingerprint: String = fp.hex();
|
||||
|
||||
@@ -4,8 +4,9 @@ use tokio::fs;
|
||||
|
||||
use super::*;
|
||||
use crate::chat::{
|
||||
ChatItem, ChatVisibility, add_contact_to_chat, add_to_chat_contacts_table, create_group,
|
||||
get_chat_contacts, get_chat_msgs, is_contact_in_chat, remove_contact_from_chat, send_text_msg,
|
||||
CantSendReason, ChatItem, ChatVisibility, add_contact_to_chat, add_to_chat_contacts_table,
|
||||
create_group, get_chat_contacts, get_chat_msgs, is_contact_in_chat, remove_contact_from_chat,
|
||||
send_text_msg,
|
||||
};
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::constants::DC_GCL_FOR_FORWARDING;
|
||||
@@ -3283,7 +3284,8 @@ async fn test_blocked_contact_creates_group() -> Result<()> {
|
||||
|
||||
let sent = bob.send_text(group_id, "Heyho, I'm a spammer!").await;
|
||||
let rcvd = alice.recv_msg(&sent).await;
|
||||
// Alice blocked Bob, so she shouldn't get the message
|
||||
// Alice blocked Bob, so she shouldn't be notified.
|
||||
assert_eq!(rcvd.state, MessageState::InSeen);
|
||||
assert_eq!(rcvd.chat_blocked, Blocked::Yes);
|
||||
|
||||
// Fiona didn't block Bob, though, so she gets the message
|
||||
@@ -3878,6 +3880,29 @@ async fn test_group_contacts_goto_bottom() -> Result<()> {
|
||||
let contacts = Contact::get_all(bob, 0, None).await?;
|
||||
assert_eq!(contacts.len(), 2);
|
||||
assert_eq!(contacts[0], bob_fiona_id);
|
||||
|
||||
send_text_msg(
|
||||
bob,
|
||||
bob_chat_id,
|
||||
"Hi Alice, stay down in my contact list".to_string(),
|
||||
)
|
||||
.await?;
|
||||
bob.pop_sent_msg().await;
|
||||
let contacts = Contact::get_all(bob, 0, None).await?;
|
||||
assert_eq!(contacts[0], bob_fiona_id);
|
||||
|
||||
remove_contact_from_chat(bob, bob_chat_id, bob_fiona_id).await?;
|
||||
bob.pop_sent_msg().await;
|
||||
let contacts = Contact::get_all(bob, 0, None).await?;
|
||||
// Fiona is still the 0th contact. This makes sense, maybe Bob is going to remove Alice from the
|
||||
// chat too, so no need to make Alice a more "important" contact yet.
|
||||
assert_eq!(contacts[0], bob_fiona_id);
|
||||
|
||||
send_text_msg(bob, bob_chat_id, "Alice, jump up!".to_string()).await?;
|
||||
bob.pop_sent_msg().await;
|
||||
let contacts = Contact::get_all(bob, 0, None).await?;
|
||||
let bob_alice_id = bob.add_or_lookup_contact_id(alice).await;
|
||||
assert_eq!(contacts[0], bob_alice_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5083,6 +5108,100 @@ async fn test_dont_verify_by_verified_by_unknown() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recv_outgoing_msg_before_securejoin() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
let a0 = &tcm.elena().await;
|
||||
let a1 = &tcm.elena().await;
|
||||
|
||||
tcm.execute_securejoin(bob, a0).await;
|
||||
let chat_id_a0_bob = a0.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Group);
|
||||
assert!(!chat_a1.is_encrypted(a1).await?);
|
||||
assert_eq!(
|
||||
chat::get_chat_contacts(a1, chat_a1.id).await?,
|
||||
[a1.add_or_lookup_address_contact_id(bob).await]
|
||||
);
|
||||
assert_eq!(
|
||||
chat_a1.why_cant_send(a1).await?,
|
||||
Some(CantSendReason::NotAMember)
|
||||
);
|
||||
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
assert_eq!(msg_a1.chat_id, chat_a1.id);
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(
|
||||
chat_a1.why_cant_send(a1).await?,
|
||||
Some(CantSendReason::NotAMember)
|
||||
);
|
||||
|
||||
let msg_a1 = tcm.send_recv(bob, a1, "Hi back").await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Single);
|
||||
assert!(chat_a1.is_encrypted(a1).await?);
|
||||
// Weird, but fine, anyway the bigger problem is the conversation split into two chats.
|
||||
assert_eq!(
|
||||
chat_a1.why_cant_send(a1).await?,
|
||||
Some(CantSendReason::ContactRequest)
|
||||
);
|
||||
|
||||
let a0 = &tcm.alice().await;
|
||||
let a1 = &tcm.alice().await;
|
||||
tcm.execute_securejoin(bob, a0).await;
|
||||
let chat_id_a0_bob = a0.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Single);
|
||||
assert!(chat_a1.is_encrypted(a1).await?);
|
||||
assert_eq!(
|
||||
chat::get_chat_contacts(a1, chat_a1.id).await?,
|
||||
[a1.add_or_lookup_contact_id(bob).await]
|
||||
);
|
||||
assert!(chat_a1.can_send(a1).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recv_outgoing_msg_before_having_key_and_after() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let a0 = &tcm.elena().await;
|
||||
let a1 = &tcm.elena().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
tcm.execute_securejoin(bob, a0).await;
|
||||
let chat_id_a0_bob = a0.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Group);
|
||||
assert!(!chat_a1.is_encrypted(a1).await?);
|
||||
|
||||
// Device a1 somehow learns Bob's key and creates the corresponding chat. However, this doesn't
|
||||
// help because we only look up key contacts by address in a particular chat and the new chat
|
||||
// isn't referenced by the received message. This is fixed by sending and receiving Intended
|
||||
// Recipient Fingerprint subpackets which elena doesn't send.
|
||||
a1.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
assert_eq!(msg_a1.chat_id, chat_a1.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_sanitize_filename_in_received() -> Result<()> {
|
||||
let alice = &TestContext::new_alice().await;
|
||||
@@ -5342,16 +5461,14 @@ async fn test_encrypted_adhoc_group_message() -> Result<()> {
|
||||
assert_eq!(chat.is_encrypted(bob).await?, false);
|
||||
|
||||
let contact_ids = get_chat_contacts(bob, chat.id).await?;
|
||||
assert_eq!(contact_ids.len(), 3);
|
||||
assert!(chat.is_self_in_chat(bob).await?);
|
||||
assert_eq!(contact_ids.len(), 2);
|
||||
assert!(!chat.is_self_in_chat(bob).await?);
|
||||
|
||||
// Since the group is unencrypted, all contacts have
|
||||
// to be address-contacts.
|
||||
for contact_id in contact_ids {
|
||||
let contact = Contact::get_by_id(bob, contact_id).await?;
|
||||
if contact_id != ContactId::SELF {
|
||||
assert_eq!(contact.is_key_contact(), false);
|
||||
}
|
||||
assert_eq!(contact.is_key_contact(), false);
|
||||
}
|
||||
|
||||
// `from_id` of the message corresponds to key-contact of Alice
|
||||
|
||||
112
src/scheduler.rs
112
src/scheduler.rs
@@ -11,7 +11,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
pub(crate) use self::connectivity::ConnectivityStore;
|
||||
use crate::config::{self, Config};
|
||||
use crate::config::Config;
|
||||
use crate::contact::{ContactId, RecentlySeenLoop};
|
||||
use crate::context::Context;
|
||||
use crate::download::{download_known_post_messages_without_pre_message, download_msgs};
|
||||
@@ -256,14 +256,6 @@ impl SchedulerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Interrupt optional boxes (mvbox currently) loops.
|
||||
pub(crate) async fn interrupt_oboxes(&self) {
|
||||
let inner = self.inner.read().await;
|
||||
if let InnerSchedulerState::Started(ref scheduler) = *inner {
|
||||
scheduler.interrupt_oboxes();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn interrupt_smtp(&self) {
|
||||
let inner = self.inner.read().await;
|
||||
if let InnerSchedulerState::Started(ref scheduler) = *inner {
|
||||
@@ -477,29 +469,6 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
};
|
||||
|
||||
maybe_send_stats(ctx).await.log_err(ctx).ok();
|
||||
match ctx.get_config_bool(Config::FetchedExistingMsgs).await {
|
||||
Ok(fetched_existing_msgs) => {
|
||||
if !fetched_existing_msgs {
|
||||
// Consider it done even if we fail.
|
||||
//
|
||||
// This operation is not critical enough to retry,
|
||||
// especially if the error is persistent.
|
||||
if let Err(err) = ctx
|
||||
.set_config_internal(Config::FetchedExistingMsgs, config::from_bool(true))
|
||||
.await
|
||||
{
|
||||
warn!(ctx, "Can't set Config::FetchedExistingMsgs: {:#}", err);
|
||||
}
|
||||
|
||||
if let Err(err) = imap.fetch_existing_msgs(ctx, &mut session).await {
|
||||
warn!(ctx, "Failed to fetch existing messages: {:#}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(ctx, "Can't get Config::FetchedExistingMsgs: {:#}", err);
|
||||
}
|
||||
}
|
||||
|
||||
session
|
||||
.update_metadata(ctx)
|
||||
@@ -542,65 +511,24 @@ async fn fetch_idle(
|
||||
.context("store_seen_flags_on_imap")?;
|
||||
}
|
||||
|
||||
if !ctx.should_delete_to_trash().await?
|
||||
|| ctx
|
||||
.get_config(Config::ConfiguredTrashFolder)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
// Fetch the watched folder.
|
||||
connection
|
||||
.fetch_move_delete(ctx, &mut session, &watch_folder, folder_meaning)
|
||||
.await
|
||||
.context("fetch_move_delete")?;
|
||||
// Fetch the watched folder.
|
||||
connection
|
||||
.fetch_move_delete(ctx, &mut session, &watch_folder, folder_meaning)
|
||||
.await
|
||||
.context("fetch_move_delete")?;
|
||||
|
||||
// Mark expired messages for deletion. Marked messages will be deleted from the server
|
||||
// on the next iteration of `fetch_move_delete`. `delete_expired_imap_messages` is not
|
||||
// called right before `fetch_move_delete` because it is not well optimized and would
|
||||
// otherwise slow down message fetching.
|
||||
delete_expired_imap_messages(ctx)
|
||||
.await
|
||||
.context("delete_expired_imap_messages")?;
|
||||
// Mark expired messages for deletion. Marked messages will be deleted from the server
|
||||
// on the next iteration of `fetch_move_delete`. `delete_expired_imap_messages` is not
|
||||
// called right before `fetch_move_delete` because it is not well optimized and would
|
||||
// otherwise slow down message fetching.
|
||||
delete_expired_imap_messages(ctx)
|
||||
.await
|
||||
.context("delete_expired_imap_messages")?;
|
||||
|
||||
download_known_post_messages_without_pre_message(ctx, &mut session).await?;
|
||||
download_msgs(ctx, &mut session)
|
||||
.await
|
||||
.context("download_msgs")?;
|
||||
} else if folder_config == Config::ConfiguredInboxFolder {
|
||||
session.last_full_folder_scan.lock().await.take();
|
||||
}
|
||||
|
||||
// Scan additional folders only after finishing fetching the watched folder.
|
||||
//
|
||||
// On iOS the application has strictly limited time to work in background, so we may not
|
||||
// be able to scan all folders before time is up if there are many of them.
|
||||
if folder_config == Config::ConfiguredInboxFolder {
|
||||
// Only scan on the Inbox thread in order to prevent parallel scans, which might lead to duplicate messages
|
||||
match connection
|
||||
.scan_folders(ctx, &mut session)
|
||||
.await
|
||||
.context("scan_folders")
|
||||
{
|
||||
Err(err) => {
|
||||
// Don't reconnect, if there is a problem with the connection we will realize this when IDLEing
|
||||
// but maybe just one folder can't be selected or something
|
||||
warn!(ctx, "{:#}", err);
|
||||
}
|
||||
Ok(true) => {
|
||||
// Fetch the watched folder again in case scanning other folder moved messages
|
||||
// there.
|
||||
//
|
||||
// In most cases this will select the watched folder and return because there are
|
||||
// no new messages. We want to select the watched folder anyway before going IDLE
|
||||
// there, so this does not take additional protocol round-trip.
|
||||
connection
|
||||
.fetch_move_delete(ctx, &mut session, &watch_folder, folder_meaning)
|
||||
.await
|
||||
.context("fetch_move_delete after scan_folders")?;
|
||||
}
|
||||
Ok(false) => {}
|
||||
}
|
||||
}
|
||||
download_known_post_messages_without_pre_message(ctx, &mut session).await?;
|
||||
download_msgs(ctx, &mut session)
|
||||
.await
|
||||
.context("download_msgs")?;
|
||||
|
||||
// Synchronize Seen flags.
|
||||
session
|
||||
@@ -925,12 +853,6 @@ impl Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
fn interrupt_oboxes(&self) {
|
||||
for b in &self.oboxes {
|
||||
b.conn_state.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
fn interrupt_smtp(&self) {
|
||||
self.smtp.interrupt();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use anyhow::Result;
|
||||
use humansize::{BINARY, format_size};
|
||||
|
||||
use crate::events::EventType;
|
||||
use crate::imap::{FolderMeaning, scan_folders::get_watched_folder_configs};
|
||||
use crate::imap::{FolderMeaning, get_watched_folder_configs};
|
||||
use crate::quota::{QUOTA_ERROR_THRESHOLD_PERCENTAGE, QUOTA_WARN_THRESHOLD_PERCENTAGE};
|
||||
use crate::stock_str;
|
||||
use crate::{context::Context, log::LogExt};
|
||||
|
||||
@@ -784,7 +784,7 @@ fn encrypted_and_signed(
|
||||
mimeparser: &MimeMessage,
|
||||
expected_fingerprint: &Fingerprint,
|
||||
) -> bool {
|
||||
if let Some(signature) = mimeparser.signature.as_ref() {
|
||||
if let Some((signature, _)) = mimeparser.signature.as_ref() {
|
||||
if signature == expected_fingerprint {
|
||||
true
|
||||
} else {
|
||||
|
||||
69
src/smtp.rs
69
src/smtp.rs
@@ -13,7 +13,7 @@ use crate::config::Config;
|
||||
use crate::contact::{Contact, ContactId};
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::log::warn;
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::message::Message;
|
||||
use crate::message::{self, MsgId};
|
||||
use crate::mimefactory::MimeFactory;
|
||||
@@ -184,6 +184,9 @@ pub(crate) async fn smtp_send(
|
||||
smtp: &mut Smtp,
|
||||
msg_id: Option<MsgId>,
|
||||
) -> SendResult {
|
||||
if recipients.is_empty() {
|
||||
return SendResult::Success;
|
||||
}
|
||||
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
|
||||
info!(context, "SMTP-sending out mime message:\n{message}");
|
||||
}
|
||||
@@ -374,15 +377,15 @@ pub(crate) async fn send_msg_to_smtp(
|
||||
return Ok(());
|
||||
};
|
||||
if retries > 6 {
|
||||
if let Some(mut msg) = Message::load_from_db_optional(context, msg_id).await? {
|
||||
message::set_msg_failed(context, &mut msg, "Number of retries exceeded the limit.")
|
||||
.await?;
|
||||
}
|
||||
context
|
||||
.sql
|
||||
.execute("DELETE FROM smtp WHERE id=?", (rowid,))
|
||||
.await
|
||||
.context("Failed to remove message with exceeded retry limit from smtp table")?;
|
||||
if let Some(mut msg) = Message::load_from_db_optional(context, msg_id).await? {
|
||||
message::set_msg_failed(context, &mut msg, "Number of retries exceeded the limit.")
|
||||
.await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
info!(
|
||||
@@ -570,17 +573,32 @@ async fn send_mdn_rfc724_mid(
|
||||
additional_rfc724_mids.clone(),
|
||||
)
|
||||
.await?;
|
||||
let encrypted = mimefactory.will_be_encrypted();
|
||||
let rendered_msg = mimefactory.render(context).await?;
|
||||
let body = rendered_msg.message;
|
||||
|
||||
let addr = contact.get_addr();
|
||||
let recipient = async_smtp::EmailAddress::new(addr.to_string())
|
||||
.map_err(|err| format_err!("invalid recipient: {addr} {err:?}"))?;
|
||||
let recipients = vec![recipient];
|
||||
let mut recipients = Vec::new();
|
||||
if contact_id != ContactId::SELF {
|
||||
recipients.push(contact.get_addr().to_string());
|
||||
}
|
||||
if context.get_config_bool(Config::BccSelf).await? {
|
||||
add_self_recipients(context, &mut recipients, encrypted).await?;
|
||||
}
|
||||
let recipients: Vec<_> = recipients
|
||||
.into_iter()
|
||||
.filter_map(|addr| {
|
||||
async_smtp::EmailAddress::new(addr.clone())
|
||||
.with_context(|| format!("Invalid recipient: {addr}"))
|
||||
.log_err(context)
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
match smtp_send(context, &recipients, &body, smtp, None).await {
|
||||
SendResult::Success => {
|
||||
info!(context, "Successfully sent MDN for {rfc724_mid}.");
|
||||
if !recipients.is_empty() {
|
||||
info!(context, "Successfully sent MDN for {rfc724_mid}.");
|
||||
}
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
@@ -667,3 +685,34 @@ async fn send_mdn(context: &Context, smtp: &mut Smtp) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds self-addresses to `recipients` as necessary.
|
||||
/// This doesn't check `Config::BccSelf`, it should be checked by the caller if needed.
|
||||
pub(crate) async fn add_self_recipients(
|
||||
context: &Context,
|
||||
recipients: &mut Vec<String>,
|
||||
encrypted: bool,
|
||||
) -> Result<()> {
|
||||
// Previous versions of Delta Chat did not send BCC self
|
||||
// if DeleteServerAfter was set to immediately delete messages
|
||||
// from the server. This is not the case anymore
|
||||
// because BCC-self messages are also used to detect
|
||||
// that message was sent if SMTP server is slow to respond
|
||||
// and connection is frequently lost
|
||||
// before receiving status line. NB: This is not a problem for chatmail servers, so `BccSelf`
|
||||
// disabled by default is fine.
|
||||
if context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty() {
|
||||
// Avoid sending unencrypted messages to all transports, chatmail relays won't accept
|
||||
// them. Normally the user should have a non-chatmail primary transport to send unencrypted
|
||||
// messages.
|
||||
if encrypted {
|
||||
for addr in context.get_secondary_self_addrs().await? {
|
||||
recipients.push(addr);
|
||||
}
|
||||
}
|
||||
// `from` must be the last addr, see `receive_imf_inner()` why.
|
||||
let from = context.get_primary_self_addr().await?;
|
||||
recipients.push(from);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
21
src/sql.rs
21
src/sql.rs
@@ -9,6 +9,7 @@ use rusqlite::{Connection, OpenFlags, Row, config::DbConfig, types::ValueRef};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::add_device_msg;
|
||||
use crate::config::Config;
|
||||
use crate::constants::DC_CHAT_ID_TRASH;
|
||||
use crate::context::Context;
|
||||
@@ -17,11 +18,13 @@ use crate::ephemeral::start_ephemeral_timers;
|
||||
use crate::imex::BLOBS_BACKUP_NAME;
|
||||
use crate::location::delete_orphaned_poi_locations;
|
||||
use crate::log::{LogExt, warn};
|
||||
use crate::message::Message;
|
||||
use crate::message::MsgId;
|
||||
use crate::net::dns::prune_dns_cache;
|
||||
use crate::net::http::http_cache_cleanup;
|
||||
use crate::net::prune_connection_history;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::stock_str;
|
||||
use crate::tools::{SystemTime, Time, delete_file, time, time_elapsed};
|
||||
|
||||
/// Extension to [`rusqlite::ToSql`] trait
|
||||
@@ -865,6 +868,12 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
maybe_add_mvbox_move_deprecation_message(context)
|
||||
.await
|
||||
.context("maybe_add_mvbox_move_deprecation_message")
|
||||
.log_err(context)
|
||||
.ok();
|
||||
|
||||
if let Err(err) = incremental_vacuum(context).await {
|
||||
warn!(context, "Failed to run incremental vacuum: {err:#}.");
|
||||
}
|
||||
@@ -924,6 +933,18 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds device message about `mvbox_move` config deprecation
|
||||
/// if the user has it enabled.
|
||||
async fn maybe_add_mvbox_move_deprecation_message(context: &Context) -> Result<()> {
|
||||
if !context.get_config_bool(Config::OnlyFetchMvbox).await?
|
||||
&& context.get_config_bool(Config::MvboxMove).await?
|
||||
{
|
||||
let mut msg = Message::new_text(stock_str::mvbox_move_deprecation(context).await);
|
||||
add_device_msg(context, Some("mvbox_move_deprecation"), Some(&mut msg)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the value of a column `idx` of the `row` as `Vec<u8>`.
|
||||
pub fn row_get_vec(row: &Row, idx: usize) -> rusqlite::Result<Vec<u8>> {
|
||||
row.get(idx).or_else(|err| match row.get_ref(idx)? {
|
||||
|
||||
@@ -1525,6 +1525,39 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Copy of migration 144, but inserts a value for `mvbox_move`
|
||||
// if it does not exist, because no value is treated as `1`.
|
||||
inc_and_check(&mut migration_version, 146)?;
|
||||
if dbversion < migration_version {
|
||||
sql.execute_migration_transaction(
|
||||
|transaction| {
|
||||
let is_chatmail = transaction
|
||||
.query_row(
|
||||
"SELECT value FROM config WHERE keyname='is_chatmail'",
|
||||
(),
|
||||
|row| {
|
||||
let value: String = row.get(0)?;
|
||||
Ok(value)
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.as_deref()
|
||||
== Some("1");
|
||||
|
||||
if is_chatmail {
|
||||
transaction.execute_batch(
|
||||
"DELETE FROM config WHERE keyname='only_fetch_mvbox';
|
||||
DELETE FROM config WHERE keyname='show_emails';
|
||||
INSERT OR REPLACE INTO config (keyname, value) VALUES ('mvbox_move', '0')",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
|
||||
@@ -96,9 +96,6 @@ async fn test_key_contacts_migration_email1() -> Result<()> {
|
||||
assert_eq!(email_bob.fingerprint(), None);
|
||||
assert_eq!(email_bob.get_verifier_id(&t).await?, None);
|
||||
|
||||
let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?;
|
||||
dbg!(&bob_chat_contacts);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -137,9 +134,6 @@ async fn test_key_contacts_migration_email2() -> Result<()> {
|
||||
assert_eq!(email_bob.fingerprint(), None);
|
||||
assert_eq!(email_bob.get_verifier_id(&t).await?, None);
|
||||
|
||||
let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?;
|
||||
dbg!(&bob_chat_contacts);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -176,7 +170,6 @@ async fn test_key_contacts_migration_verified() -> Result<()> {
|
||||
let pgp_bob_id = bob_chat_contacts[0];
|
||||
|
||||
let pgp_bob = Contact::get_by_id(&t, pgp_bob_id).await?;
|
||||
dbg!(&pgp_bob);
|
||||
assert_eq!(pgp_bob.origin, Origin::OutgoingTo);
|
||||
assert_eq!(pgp_bob.e2ee_avail(&t).await?, true);
|
||||
assert_eq!(
|
||||
|
||||
15
src/stats.rs
15
src/stats.rs
@@ -8,7 +8,8 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use anyhow::{Context as _, Result};
|
||||
use deltachat_derive::FromSql;
|
||||
use num_traits::ToPrimitive;
|
||||
use pgp::types::PublicKeyTrait;
|
||||
use pgp::types::KeyDetails as _;
|
||||
use rand::distr::SampleString as _;
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -21,7 +22,7 @@ use crate::key::load_self_public_keyring;
|
||||
use crate::log::LogExt;
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::securejoin::QrInvite;
|
||||
use crate::tools::{create_id, time};
|
||||
use crate::tools::time;
|
||||
|
||||
pub(crate) const STATISTICS_BOT_EMAIL: &str = "self_reporting@testrun.org";
|
||||
const STATISTICS_BOT_VCARD: &str = include_str!("../assets/statistics-bot.vcf");
|
||||
@@ -32,7 +33,7 @@ const MESSAGE_STATS_UPDATE_INTERVAL_SECONDS: i64 = 4 * 60; // 4 minutes (less th
|
||||
#[derive(Serialize)]
|
||||
struct Statistics {
|
||||
core_version: String,
|
||||
key_create_timestamps: Vec<i64>,
|
||||
key_create_timestamps: Vec<u32>,
|
||||
stats_id: String,
|
||||
is_chatmail: bool,
|
||||
contact_stats: Vec<ContactStat>,
|
||||
@@ -344,10 +345,10 @@ async fn get_stats(context: &Context) -> Result<String> {
|
||||
.get_config_u32(Config::StatsLastOldContactId)
|
||||
.await?;
|
||||
|
||||
let key_create_timestamps: Vec<i64> = load_self_public_keyring(context)
|
||||
let key_create_timestamps: Vec<u32> = load_self_public_keyring(context)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|k| k.created_at().timestamp())
|
||||
.map(|k| k.created_at().as_secs())
|
||||
.collect();
|
||||
|
||||
let sending_enabled_timestamps =
|
||||
@@ -390,7 +391,9 @@ pub(crate) async fn stats_id(context: &Context) -> Result<String> {
|
||||
Ok(match context.get_config(Config::StatsId).await? {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = create_id();
|
||||
let id = rand::distr::Alphabetic
|
||||
.sample_string(&mut rand::rng(), 25)
|
||||
.to_lowercase();
|
||||
context
|
||||
.set_config_internal(Config::StatsId, Some(&id))
|
||||
.await?;
|
||||
|
||||
@@ -63,10 +63,7 @@ pub enum StockMessage {
|
||||
#[strum(props(fallback = "GIF"))]
|
||||
Gif = 23,
|
||||
|
||||
#[strum(props(fallback = "End-to-end encryption available"))]
|
||||
E2eAvailable = 25,
|
||||
|
||||
#[strum(props(fallback = "No encryption"))]
|
||||
#[strum(props(fallback = "No encryption."))]
|
||||
EncrNone = 28,
|
||||
|
||||
#[strum(props(fallback = "Fingerprints"))]
|
||||
@@ -372,12 +369,6 @@ Help keeping us to keep Delta Chat independent and make it more awesome in the f
|
||||
https://delta.chat/donate"))]
|
||||
DonationRequest = 193,
|
||||
|
||||
#[strum(props(fallback = "Outgoing call"))]
|
||||
OutgoingCall = 194,
|
||||
|
||||
#[strum(props(fallback = "Incoming call"))]
|
||||
IncomingCall = 195,
|
||||
|
||||
#[strum(props(fallback = "Declined call"))]
|
||||
DeclinedCall = 196,
|
||||
|
||||
@@ -415,6 +406,23 @@ https://delta.chat/donate"))]
|
||||
|
||||
#[strum(props(fallback = "Messages in this chat use classic email and are not encrypted."))]
|
||||
ChatUnencryptedExplanation = 230,
|
||||
|
||||
#[strum(props(
|
||||
fallback = "You are using the legacy option \"Settings → Advanced → Move automatically to DeltaChat Folder\".\n\nThis option will be removed in a few weeks and you should disable it already today.\n\nIf having chat messages mixed into your inbox is a problem, see https://delta.chat/legacy-move"
|
||||
))]
|
||||
MvboxMoveDeprecation = 231,
|
||||
|
||||
#[strum(props(fallback = "Outgoing audio call"))]
|
||||
OutgoingAudioCall = 232,
|
||||
|
||||
#[strum(props(fallback = "Outgoing video call"))]
|
||||
OutgoingVideoCall = 233,
|
||||
|
||||
#[strum(props(fallback = "Incoming audio call"))]
|
||||
IncomingAudioCall = 234,
|
||||
|
||||
#[strum(props(fallback = "Incoming video call"))]
|
||||
IncomingVideoCall = 235,
|
||||
}
|
||||
|
||||
impl StockMessage {
|
||||
@@ -707,11 +715,6 @@ pub(crate) async fn gif(context: &Context) -> String {
|
||||
translated(context, StockMessage::Gif).await
|
||||
}
|
||||
|
||||
/// Stock string: `End-to-end encryption available.`.
|
||||
pub(crate) async fn e2e_available(context: &Context) -> String {
|
||||
translated(context, StockMessage::E2eAvailable).await
|
||||
}
|
||||
|
||||
/// Stock string: `No encryption.`.
|
||||
pub(crate) async fn encr_none(context: &Context) -> String {
|
||||
translated(context, StockMessage::EncrNone).await
|
||||
@@ -765,14 +768,30 @@ pub(crate) async fn donation_request(context: &Context) -> String {
|
||||
translated(context, StockMessage::DonationRequest).await
|
||||
}
|
||||
|
||||
/// Stock string: `Outgoing call`.
|
||||
pub(crate) async fn outgoing_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::OutgoingCall).await
|
||||
/// Stock string: `Outgoing video call` or `Outgoing audio call`.
|
||||
pub(crate) async fn outgoing_call(context: &Context, has_video: bool) -> String {
|
||||
translated(
|
||||
context,
|
||||
if has_video {
|
||||
StockMessage::OutgoingVideoCall
|
||||
} else {
|
||||
StockMessage::OutgoingAudioCall
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stock string: `Incoming call`.
|
||||
pub(crate) async fn incoming_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::IncomingCall).await
|
||||
/// Stock string: `Incoming video call` or `Incoming audio call`.
|
||||
pub(crate) async fn incoming_call(context: &Context, has_video: bool) -> String {
|
||||
translated(
|
||||
context,
|
||||
if has_video {
|
||||
StockMessage::IncomingVideoCall
|
||||
} else {
|
||||
StockMessage::IncomingAudioCall
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stock string: `Declined call`.
|
||||
@@ -1231,6 +1250,11 @@ pub(crate) async fn chat_unencrypted_explanation(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatUnencryptedExplanation).await
|
||||
}
|
||||
|
||||
/// Stock string: `You are using the legacy option "Move automatically to DeltaChat Folder`…
|
||||
pub(crate) async fn mvbox_move_deprecation(context: &Context) -> String {
|
||||
translated(context, StockMessage::MvboxMoveDeprecation).await
|
||||
}
|
||||
|
||||
impl Viewtype {
|
||||
/// returns Localized name for message viewtype
|
||||
pub async fn to_locale_string(&self, context: &Context) -> String {
|
||||
|
||||
@@ -4,6 +4,9 @@ use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::str;
|
||||
|
||||
use anyhow::Result;
|
||||
use num_traits::FromPrimitive;
|
||||
|
||||
use crate::calls::{CallState, call_state};
|
||||
use crate::chat::Chat;
|
||||
use crate::constants::Chattype;
|
||||
@@ -15,7 +18,6 @@ use crate::param::Param;
|
||||
use crate::stock_str;
|
||||
use crate::stock_str::msg_reacted;
|
||||
use crate::tools::truncate;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Prefix displayed before message and separated by ":" in the chatlist.
|
||||
#[derive(Debug)]
|
||||
@@ -167,7 +169,15 @@ impl Message {
|
||||
/// Returns a summary text without "Forwarded:" prefix.
|
||||
async fn get_summary_text_without_prefix(&self, context: &Context) -> String {
|
||||
let (emoji, type_name, type_file, append_text);
|
||||
match self.viewtype {
|
||||
let viewtype = match self
|
||||
.param
|
||||
.get_i64(Param::PostMessageViewtype)
|
||||
.and_then(Viewtype::from_i64)
|
||||
{
|
||||
Some(vt) => vt,
|
||||
None => self.viewtype,
|
||||
};
|
||||
match viewtype {
|
||||
Viewtype::Image => {
|
||||
emoji = Some("📷");
|
||||
type_name = Some(stock_str::image(context).await);
|
||||
@@ -211,33 +221,44 @@ impl Message {
|
||||
append_text = true
|
||||
}
|
||||
Viewtype::Webxdc => {
|
||||
emoji = None;
|
||||
type_name = None;
|
||||
type_file = Some(
|
||||
self.get_webxdc_info(context)
|
||||
.await
|
||||
.map(|info| info.name)
|
||||
.unwrap_or_else(|_| "ErrWebxdcName".to_string()),
|
||||
);
|
||||
if self.viewtype == Viewtype::Webxdc {
|
||||
emoji = None;
|
||||
type_file = Some(
|
||||
self.get_webxdc_info(context)
|
||||
.await
|
||||
.map(|info| info.name)
|
||||
.unwrap_or_else(|_| "ErrWebxdcName".to_string()),
|
||||
);
|
||||
} else {
|
||||
emoji = Some("📱");
|
||||
type_file = Some(viewtype.to_locale_string(context).await);
|
||||
}
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Vcard => {
|
||||
emoji = Some("👤");
|
||||
type_name = None;
|
||||
type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
|
||||
if self.viewtype == Viewtype::Vcard {
|
||||
type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
|
||||
} else {
|
||||
type_file = None;
|
||||
}
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Call => {
|
||||
let call_info = context.load_call_by_id(self.id).await.unwrap_or(None);
|
||||
let has_video = call_info.is_some_and(|c| c.has_video_initially());
|
||||
let call_state = call_state(context, self.id)
|
||||
.await
|
||||
.unwrap_or(CallState::Alerting);
|
||||
emoji = Some("📞");
|
||||
emoji = Some(if has_video { "🎥" } else { "📞" });
|
||||
type_name = Some(match call_state {
|
||||
CallState::Alerting | CallState::Active | CallState::Completed { .. } => {
|
||||
if self.from_id == ContactId::SELF {
|
||||
stock_str::outgoing_call(context).await
|
||||
stock_str::outgoing_call(context, has_video).await
|
||||
} else {
|
||||
stock_str::incoming_call(context).await
|
||||
stock_str::incoming_call(context, has_video).await
|
||||
}
|
||||
}
|
||||
CallState::Missed => stock_str::missed_call(context).await,
|
||||
@@ -261,7 +282,7 @@ impl Message {
|
||||
}
|
||||
};
|
||||
|
||||
let text = self.text.clone() + &self.additional_text;
|
||||
let text = self.text.clone();
|
||||
|
||||
let summary = if let Some(type_file) = type_file {
|
||||
if append_text && !text.is_empty() {
|
||||
@@ -293,6 +314,13 @@ impl Message {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Asserts that the summary text with and w/o prefix is `expected`.
|
||||
pub async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
|
||||
assert_eq!(msg.get_summary_text(ctx).await, expected);
|
||||
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
@@ -302,11 +330,6 @@ mod tests {
|
||||
use crate::param::Param;
|
||||
use crate::test_utils::TestContext;
|
||||
|
||||
async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
|
||||
assert_eq!(msg.get_summary_text(ctx).await, expected);
|
||||
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_get_summary_text() {
|
||||
let d = TestContext::new_alice().await;
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::contact::{
|
||||
};
|
||||
use crate::context::Context;
|
||||
use crate::events::{Event, EventEmitter, EventType, Events};
|
||||
use crate::key::{self, DcKey, DcSecretKey, self_fingerprint};
|
||||
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};
|
||||
@@ -44,7 +44,7 @@ use crate::stock_str::StockStrings;
|
||||
use crate::tools::time;
|
||||
|
||||
/// The number of info messages added to new e2ee chats.
|
||||
/// Currently this is "End-to-end encryption available", string `E2eAvailable`.
|
||||
/// Currently this is "Messages are end-to-end encrypted.", string `ChatProtectionEnabled`.
|
||||
pub const E2EE_INFO_MSGS: usize = 1;
|
||||
|
||||
#[allow(non_upper_case_globals)]
|
||||
@@ -117,6 +117,8 @@ impl TestContextManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns new elena's "device".
|
||||
/// Elena doesn't send Intended Recipient Fingerprint subpackets to simulate old Delta Chat.
|
||||
pub async fn elena(&mut self) -> TestContext {
|
||||
TestContext::builder()
|
||||
.configure_elena()
|
||||
@@ -565,6 +567,7 @@ impl TestContext {
|
||||
.unwrap();
|
||||
ctx.set_config(Config::BccSelf, Some("1")).await.unwrap();
|
||||
ctx.set_config(Config::SyncMsgs, Some("0")).await.unwrap();
|
||||
ctx.set_config(Config::MvboxMove, Some("0")).await.unwrap();
|
||||
|
||||
Self {
|
||||
ctx,
|
||||
@@ -1352,7 +1355,7 @@ impl SentMessage<'_> {
|
||||
pub fn alice_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc")).unwrap();
|
||||
let public = secret.split_public_key().unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
}
|
||||
|
||||
@@ -1362,7 +1365,7 @@ pub fn alice_keypair() -> KeyPair {
|
||||
pub fn bob_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
|
||||
let public = secret.split_public_key().unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
}
|
||||
|
||||
@@ -1373,7 +1376,7 @@ pub fn charlie_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/charlie-secret.asc"))
|
||||
.unwrap();
|
||||
let public = secret.split_public_key().unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
}
|
||||
|
||||
@@ -1383,7 +1386,7 @@ pub fn charlie_keypair() -> KeyPair {
|
||||
pub fn dom_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/dom-secret.asc")).unwrap();
|
||||
let public = secret.split_public_key().unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
}
|
||||
|
||||
@@ -1393,7 +1396,7 @@ pub fn dom_keypair() -> KeyPair {
|
||||
pub fn elena_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/elena-secret.asc")).unwrap();
|
||||
let public = secret.split_public_key().unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
}
|
||||
|
||||
@@ -1403,7 +1406,7 @@ pub fn elena_keypair() -> KeyPair {
|
||||
pub fn fiona_keypair() -> KeyPair {
|
||||
let secret =
|
||||
key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc")).unwrap();
|
||||
let public = secret.split_public_key().unwrap();
|
||||
let public = secret.to_public_key();
|
||||
KeyPair { public, secret }
|
||||
}
|
||||
|
||||
@@ -1492,6 +1495,15 @@ impl EventTracker {
|
||||
pub fn clear_events(&self) {
|
||||
while let Ok(_ev) = self.try_recv() {}
|
||||
}
|
||||
|
||||
/// Takes all items from event queue and returns them.
|
||||
pub fn take_events(&self) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a specific message from a chat and asserts that the chat has a specific length.
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::message::{Message, MessageState, Viewtype, delete_msgs, markseen_msgs
|
||||
use crate::mimeparser::MimeMessage;
|
||||
use crate::param::Param;
|
||||
use crate::reaction::{get_msg_reactions, send_reaction};
|
||||
use crate::summary::assert_summary_texts;
|
||||
use crate::test_utils::TestContextManager;
|
||||
use crate::tests::pre_messages::util::{
|
||||
send_large_file_message, send_large_image_message, send_large_webxdc_message,
|
||||
@@ -76,6 +77,39 @@ async fn test_receive_pre_message() -> Result<()> {
|
||||
assert_eq!(msg.get_filebytes(bob).await?, Some(1_000_000));
|
||||
assert_eq!(msg.get_post_message_viewtype(), Some(Viewtype::File));
|
||||
assert_eq!(msg.get_filename(), Some("test.bin".to_owned()));
|
||||
assert_summary_texts(&msg, bob, "📎 test.bin – test").await;
|
||||
|
||||
// Some viewtypes are displayed unwell currently, still test them.
|
||||
|
||||
let (pre_message, ..) = send_large_webxdc_message(alice, alice_group_id).await?;
|
||||
let msg = bob.recv_msg(&pre_message).await;
|
||||
assert_eq!(msg.download_state, DownloadState::Available);
|
||||
assert_summary_texts(
|
||||
&msg,
|
||||
bob,
|
||||
&format!("📱 {} – test", Viewtype::Webxdc.to_locale_string(bob).await),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (pre_message, ..) = send_large_file_message(
|
||||
alice,
|
||||
alice_group_id,
|
||||
Viewtype::Vcard,
|
||||
format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
EMAIL:alice@example.org\r\n\
|
||||
FN:Alice\r\n\
|
||||
NOTE:{}\r\n\
|
||||
END:VCARD\r\n",
|
||||
String::from_utf8(vec![97u8; 1_000_000])?,
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let msg = bob.recv_msg(&pre_message).await;
|
||||
assert_eq!(msg.download_state, DownloadState::Available);
|
||||
assert_summary_texts(&msg, bob, "👤 test").await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ pub async fn send_large_file_message<'a>(
|
||||
content: &[u8],
|
||||
) -> Result<(SentMessage<'a>, SentMessage<'a>, MsgId)> {
|
||||
let mut msg = Message::new(view_type);
|
||||
let file_name = if view_type == Viewtype::Webxdc {
|
||||
"test.xdc"
|
||||
} else {
|
||||
"test.bin"
|
||||
let file_name = match view_type {
|
||||
Viewtype::Webxdc => "test.xdc",
|
||||
Viewtype::Vcard => "test.vcf",
|
||||
_ => "test.bin",
|
||||
};
|
||||
msg.set_file_from_bytes(sender, file_name, content, None)?;
|
||||
msg.set_text("test".to_owned());
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! and configured list of connection candidates.
|
||||
|
||||
use std::fmt;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, format_err};
|
||||
use deltachat_contact_tools::{EmailAddress, addr_normalize};
|
||||
@@ -760,11 +761,18 @@ pub(crate) async fn sync_transports(
|
||||
.await?;
|
||||
|
||||
if modified {
|
||||
tokio::task::spawn(restart_io_if_running_boxed(context.clone()));
|
||||
context.emit_event(EventType::TransportsModified);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Same as `context.restart_io_if_running()`, but `Box::pin`ed and with a `+ Send` bound,
|
||||
/// so that it can be called recursively.
|
||||
fn restart_io_if_running_boxed(context: Context) -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
Box::pin(async move { context.restart_io_if_running().await })
|
||||
}
|
||||
|
||||
/// Adds transport entry to the `transports` table with empty configuration.
|
||||
pub(crate) async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
|
||||
context.sql
|
||||
|
||||
BIN
test-data/image/screenshot120x120.jpg
Normal file
BIN
test-data/image/screenshot120x120.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Reference in New Issue
Block a user