mirror of
https://github.com/chatmail/core.git
synced 2026-06-20 14:46:36 +03:00
Compare commits
32 Commits
iequidoo/d
...
link2xt/pg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8d49cc219 | ||
|
|
31f89b72dd | ||
|
|
94957e9784 | ||
|
|
b860583f8d | ||
|
|
d39b79f6fc | ||
|
|
ab03fe3040 | ||
|
|
246376259e | ||
|
|
44bdd5ef0c | ||
|
|
d844f29c68 | ||
|
|
07d6b89b85 | ||
|
|
2bcfbe99fa | ||
|
|
3e10cf2c07 | ||
|
|
c7a399e3ca | ||
|
|
14b9577c39 | ||
|
|
f961a49906 | ||
|
|
b98b32317c | ||
|
|
b94c177997 | ||
|
|
15059ad8d7 | ||
|
|
293b524373 | ||
|
|
edb8a87cf8 | ||
|
|
9f2de665fc | ||
|
|
906e99a6f7 | ||
|
|
b215bc6d16 | ||
|
|
6cd5b21a26 | ||
|
|
87c1fb2118 | ||
|
|
e8b94781a5 | ||
|
|
07231f28ae | ||
|
|
64f0d2352c | ||
|
|
93bf3d6ebb | ||
|
|
f05336f793 | ||
|
|
8df028e9a8 | ||
|
|
40309ce857 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823
|
||||
- uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe
|
||||
with:
|
||||
arguments: --workspace --all-features --locked
|
||||
command: check
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
cache-bin: false
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@60ae4ce63c7aeb6e96d7f572c1ec7fafbb17ca80
|
||||
uses: taiki-e/install-action@0631aa6515c7d545823c67cfae7ef4fc7f490154
|
||||
with:
|
||||
tool: nextest
|
||||
|
||||
|
||||
77
CHANGELOG.md
77
CHANGELOG.md
@@ -1,5 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## [2.53.0] - 2026-06-15
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Make quality of images sent in chats more consistent between images with different aspect ratio.
|
||||
- `MsgId::get_html`: Make only one db query.
|
||||
- Do not log the recipient list for sent messages.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Do not trash pre-messages without text but with a webxdc update.
|
||||
- Don't send or process webxdc status updates in pre-messages.
|
||||
- Ignore SecureJoin messages from blocked contacts ([#8295](https://github.com/chatmail/core/pull/8295)).
|
||||
- Do not abort IMAP connection if setting the push token fails.
|
||||
|
||||
### Documentation
|
||||
|
||||
- STYLE.md: Require to list columns explicitly in `INSERT` statements.
|
||||
|
||||
### Build system
|
||||
|
||||
- nix: switch to the "master" branch for naersk.
|
||||
- flake.nix: Use hostPlatform.rust.rustcTarget instead of hardcoding it.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Bump version to 2.52.0-dev.
|
||||
- deps: bump taiki-e/install-action from 2.79.10 to 2.81.1.
|
||||
- deps: bump EmbarkStudios/cargo-deny-action from 2.0.19 to 2.0.20.
|
||||
- Bump version to 2.53.0-dev.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Move the definition of the `target_wh`-variable.
|
||||
- Remove timesmearing.
|
||||
|
||||
### Tests
|
||||
|
||||
- Print multiline chat descriptions with debug formatter.
|
||||
- `exec_securejoin_qr_multi_device()`: Make inviter devices receive each other messages.
|
||||
- Fixup the tests after removing timesmearing.
|
||||
- Remove timeout from `pop_sent_msg_ex()`.
|
||||
|
||||
## [2.52.0] - 2026-06-09
|
||||
|
||||
### Fixes
|
||||
|
||||
- Update the channel title after joining if the QR code included a wrong title ([#8260](https://github.com/chatmail/core/pull/8260)).
|
||||
- Don't send removal message to contact that hasn't been a chat member ([#8298](https://github.com/chatmail/core/pull/8298)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Add cryptography-related statistics (`number_of_transports`, `key_version`, `key_algorithm`, `pubkey_size`, `number_of_keys`) ([#8293](https://github.com/chatmail/core/pull/8293), [#8297](https://github.com/chatmail/core/pull/8297)).
|
||||
- Add IMAP folder to `Context::get_info()` ([#8285](https://github.com/chatmail/core/pull/8285)).
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update preloaded DNS cache.
|
||||
- Use default aws-lc-rs cryptography provider for rustls.
|
||||
- Add exception for unmaintained proc-macro-error2 to deny.toml.
|
||||
- cargo: bump `pin-project` from 1.1.11 to 1.1.13.
|
||||
- cargo: bump `tokio` from 1.52.1 to 1.52.3.
|
||||
- cargo: bump `log` from 0.4.29 to 0.4.30.
|
||||
- cargo: bump `serde_json` from 1.0.149 to 1.0.150.
|
||||
- deps: bump EmbarkStudios/cargo-deny-action from 2.0.18 to 2.0.19.
|
||||
- deps: bump taiki-e/install-action from 2.79.2 to 2.79.10.
|
||||
|
||||
### Build system
|
||||
|
||||
- nix: fix windows cross-compilation by adding pthreads includes.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove support for building "source" packages for deltachat-rpc-server.
|
||||
|
||||
## [2.51.0] - 2026-05-29
|
||||
|
||||
### Features / Changes
|
||||
@@ -8297,3 +8372,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[2.49.0]: https://github.com/chatmail/core/compare/v2.48.0..v2.49.0
|
||||
[2.50.0]: https://github.com/chatmail/core/compare/v2.49.0..v2.50.0
|
||||
[2.51.0]: https://github.com/chatmail/core/compare/v2.50.0..v2.51.0
|
||||
[2.52.0]: https://github.com/chatmail/core/compare/v2.51.0..v2.52.0
|
||||
[2.53.0]: https://github.com/chatmail/core/compare/v2.52.0..v2.53.0
|
||||
|
||||
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -1350,7 +1350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -1459,7 +1459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
@@ -1480,7 +1480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-repl"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1496,7 +1496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1525,7 +1525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.89"
|
||||
|
||||
7
STYLE.md
7
STYLE.md
@@ -59,6 +59,13 @@ If column is already declared without `NOT NULL`, use `IFNULL` function to provi
|
||||
Use `HAVING COUNT(*) > 0` clause
|
||||
to [prevent aggregate functions such as `MIN` and `MAX` from returning `NULL`](https://stackoverflow.com/questions/66527856/aggregate-functions-max-etc-return-null-instead-of-no-rows).
|
||||
|
||||
List columns explicitly in `INSERT` statements:
|
||||
```
|
||||
INSERT OR IGNORE INTO download (rfc724_mid, msg_id) VALUES (?,0);
|
||||
```
|
||||
Otherwise if a new column with default value is added in a future DB version, an upgraded DB can't
|
||||
be used with the old code, e.g. after transferring a DB from a device running a newer version.
|
||||
|
||||
Don't delete unused columns too early, but maybe after several months/releases, unused columns are
|
||||
still used by older versions, so deleting them breaks downgrading the core or importing a backup in
|
||||
an older version. Also don't change the column type, consider adding a new column with another name
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -6341,6 +6341,15 @@ void dc_event_unref(dc_event_t* event);
|
||||
*/
|
||||
#define DC_EVENT_MSG_DELETED 2016
|
||||
|
||||
/**
|
||||
* Like @ref DC_EVENT_MSG_READ, but also fires on subsequent MDNs,
|
||||
* if there are multiple receivers, i.e. in groups and channels.
|
||||
*
|
||||
* @param data1 (int) chat_id
|
||||
* @param data2 (int) msg_id
|
||||
*/
|
||||
#define DC_EVENT_MSG_READ_COUNT_CHANGED 2018
|
||||
|
||||
|
||||
/**
|
||||
* Chat changed. The name or the image of a chat group was changed or members were added or removed.
|
||||
|
||||
@@ -528,6 +528,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
|
||||
EventType::MsgFailed { .. } => 2012,
|
||||
EventType::MsgRead { .. } => 2015,
|
||||
EventType::MsgDeleted { .. } => 2016,
|
||||
EventType::MsgReadCountChanged { .. } => 2018,
|
||||
EventType::ChatModified(_) => 2020,
|
||||
EventType::ChatEphemeralTimerModified { .. } => 2021,
|
||||
EventType::ChatDeleted { .. } => 2023,
|
||||
@@ -602,6 +603,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::MsgFailed { chat_id, .. }
|
||||
| EventType::MsgRead { chat_id, .. }
|
||||
| EventType::MsgDeleted { chat_id, .. }
|
||||
| EventType::MsgReadCountChanged { chat_id, .. }
|
||||
| EventType::ChatModified(chat_id)
|
||||
| EventType::ChatEphemeralTimerModified { chat_id, .. }
|
||||
| EventType::ChatDeleted { chat_id } => chat_id.to_u32() as libc::c_int,
|
||||
@@ -688,7 +690,8 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::MsgDelivered { msg_id, .. }
|
||||
| EventType::MsgFailed { msg_id, .. }
|
||||
| EventType::MsgRead { msg_id, .. }
|
||||
| EventType::MsgDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
|
||||
| EventType::MsgDeleted { msg_id, .. }
|
||||
| EventType::MsgReadCountChanged { msg_id, .. } => msg_id.to_u32() as libc::c_int,
|
||||
EventType::SecurejoinInviterProgress { progress, .. }
|
||||
| EventType::SecurejoinJoinerProgress { progress, .. } => *progress as libc::c_int,
|
||||
EventType::ChatEphemeralTimerModified { timer, .. } => timer.to_u32() as libc::c_int,
|
||||
@@ -762,6 +765,7 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
|
||||
| EventType::MsgFailed { .. }
|
||||
| EventType::MsgRead { .. }
|
||||
| EventType::MsgDeleted { .. }
|
||||
| EventType::MsgReadCountChanged { .. }
|
||||
| EventType::ChatModified(_)
|
||||
| EventType::ContactsChanged(_)
|
||||
| EventType::LocationChanged(_)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -203,6 +203,17 @@ pub enum EventType {
|
||||
msg_id: u32,
|
||||
},
|
||||
|
||||
/// Like [`EventType::MsgRead`], but also fires on subsequent MDNs,
|
||||
/// if there are multiple receivers, i.e. in groups and channels.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MsgReadCountChanged {
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: u32,
|
||||
|
||||
/// ID of the message that was read.
|
||||
msg_id: u32,
|
||||
},
|
||||
|
||||
/// A single message was deleted.
|
||||
///
|
||||
/// This event means that the message will no longer appear in the messagelist.
|
||||
@@ -546,6 +557,10 @@ impl From<CoreEventType> for EventType {
|
||||
chat_id: chat_id.to_u32(),
|
||||
msg_id: msg_id.to_u32(),
|
||||
},
|
||||
CoreEventType::MsgReadCountChanged { chat_id, msg_id } => MsgReadCountChanged {
|
||||
chat_id: chat_id.to_u32(),
|
||||
msg_id: msg_id.to_u32(),
|
||||
},
|
||||
CoreEventType::MsgDeleted { chat_id, msg_id } => MsgDeleted {
|
||||
chat_id: chat_id.to_u32(),
|
||||
msg_id: msg_id.to_u32(),
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.51.0-dev"
|
||||
"version": "2.54.0-dev"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
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.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
|
||||
@@ -54,6 +54,7 @@ class EventType(str, Enum):
|
||||
MSG_DELIVERED = "MsgDelivered"
|
||||
MSG_FAILED = "MsgFailed"
|
||||
MSG_READ = "MsgRead"
|
||||
MSG_READ_COUNT_CHANGED = "MsgReadCountChanged"
|
||||
MSG_DELETED = "MsgDeleted"
|
||||
CHAT_MODIFIED = "ChatModified"
|
||||
CHAT_DELETED = "ChatDeleted"
|
||||
|
||||
@@ -37,19 +37,14 @@ def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
|
||||
log.section("send out message without bcc to ourselves")
|
||||
ac1.set_config("bcc_self", "0")
|
||||
chat = ac1.create_chat(ac2)
|
||||
self_addr = ac1.get_config("addr")
|
||||
other_addr = ac2.get_config("addr")
|
||||
|
||||
msg_out = chat.send_text("message1")
|
||||
assert not msg_out.get_snapshot().is_forwarded
|
||||
|
||||
# wait for send out (no BCC)
|
||||
ev = ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
|
||||
assert self_addr not in ev.msg
|
||||
assert other_addr in ev.msg
|
||||
|
||||
log.section("ac1: setting bcc_self=1")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
|
||||
@@ -57,20 +52,16 @@ def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
|
||||
msg_out = chat.send_text("message2")
|
||||
|
||||
# wait for send out (BCC)
|
||||
ev = ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
|
||||
# Second client receives only second message, but not the first.
|
||||
# Second client receives only the second message, but not the first.
|
||||
ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED)
|
||||
assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == "Messages are end-to-end encrypted."
|
||||
|
||||
ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED)
|
||||
assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == msg_out.get_snapshot().text
|
||||
|
||||
# now make sure we are sending message to ourselves too
|
||||
assert self_addr in ev.msg
|
||||
assert self_addr in ev.msg
|
||||
|
||||
# BCC-self messages are marked as seen by the sender device.
|
||||
while True:
|
||||
event = ac1.wait_for_event()
|
||||
|
||||
@@ -1099,6 +1099,7 @@ def test_rename_group(acfactory):
|
||||
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
|
||||
|
||||
for name in ["Baz", "Foo bar", "Xyzzy"]:
|
||||
time.sleep(1)
|
||||
alice_group.set_name(name)
|
||||
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
|
||||
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.51.0-dev"
|
||||
"version": "2.54.0-dev"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ ignore = [
|
||||
# this should be fixed by upgrading to iroh 1.0 once it is released.
|
||||
"RUSTSEC-2025-0134",
|
||||
|
||||
# Unmaintained proc-macro-error2
|
||||
# Transitive dependency of typescript-type-def 0.5.13.
|
||||
# <https://rustsec.org/advisories/RUSTSEC-2026-0173>
|
||||
"RUSTSEC-2026-0173",
|
||||
|
||||
# rustls-webpki v0.102.8
|
||||
# We cannot upgrade to >=0.103.10 because
|
||||
# it is a transitive dependency of iroh 0.35.0
|
||||
|
||||
7
flake.lock
generated
7
flake.lock
generated
@@ -94,16 +94,15 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779912356,
|
||||
"narHash": "sha256-yj5O6vmAj+OfhTQMiUwhmQRP0HAII3BxEI6zuY6h/5k=",
|
||||
"lastModified": 1780914171,
|
||||
"narHash": "sha256-NYoa+CvsCgayY356worC9g6QoZkKJmvrj6nVdEA2Lyk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"rev": "33eaf5c72a67db15073322d26cd342c443556214",
|
||||
"rev": "41b6e9efb62fac9c16d5617e0456a715928a2206",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "pull/391/head",
|
||||
"repo": "naersk",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
59
flake.nix
59
flake.nix
@@ -4,7 +4,7 @@
|
||||
fenix.url = "github:nix-community/fenix";
|
||||
fenix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
naersk.url = "github:nix-community/naersk/pull/391/head";
|
||||
naersk.url = "github:nix-community/naersk";
|
||||
naersk.inputs.nixpkgs.follows = "nixpkgs";
|
||||
naersk.inputs.fenix.follows = "fenix";
|
||||
nix-filter.url = "github:numtide/nix-filter";
|
||||
@@ -66,37 +66,15 @@
|
||||
];
|
||||
};
|
||||
|
||||
# Map from architecture name to rust targets and nixpkgs targets.
|
||||
# Map from architecture name to nixpkgs targets.
|
||||
arch2targets = {
|
||||
"x86_64-linux" = {
|
||||
rustTarget = "x86_64-unknown-linux-musl";
|
||||
crossTarget = "x86_64-unknown-linux-musl";
|
||||
};
|
||||
"armv7l-linux" = {
|
||||
rustTarget = "armv7-unknown-linux-musleabihf";
|
||||
crossTarget = "armv7l-unknown-linux-musleabihf";
|
||||
};
|
||||
"armv6l-linux" = {
|
||||
rustTarget = "arm-unknown-linux-musleabihf";
|
||||
crossTarget = "armv6l-unknown-linux-musleabihf";
|
||||
};
|
||||
"aarch64-linux" = {
|
||||
rustTarget = "aarch64-unknown-linux-musl";
|
||||
crossTarget = "aarch64-unknown-linux-musl";
|
||||
};
|
||||
"i686-linux" = {
|
||||
rustTarget = "i686-unknown-linux-musl";
|
||||
crossTarget = "i686-unknown-linux-musl";
|
||||
};
|
||||
|
||||
"x86_64-darwin" = {
|
||||
rustTarget = "x86_64-apple-darwin";
|
||||
crossTarget = "x86_64-darwin";
|
||||
};
|
||||
"aarch64-darwin" = {
|
||||
rustTarget = "aarch64-apple-darwin";
|
||||
crossTarget = "aarch64-darwin";
|
||||
};
|
||||
"x86_64-linux" = "x86_64-unknown-linux-musl";
|
||||
"armv7l-linux" = "armv7l-unknown-linux-musleabihf";
|
||||
"armv6l-linux" = "armv6l-unknown-linux-musleabihf";
|
||||
"aarch64-linux" = "aarch64-unknown-linux-musl";
|
||||
"i686-linux" = "i686-unknown-linux-musl";
|
||||
"x86_64-darwin" = "x86_64-darwin";
|
||||
"aarch64-darwin" = "aarch64-darwin";
|
||||
};
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
@@ -113,10 +91,10 @@
|
||||
auditable = false; # Avoid cargo-auditable failures.
|
||||
doCheck = false; # Disable test as it requires network access.
|
||||
};
|
||||
pkgsWin64 = pkgs.pkgsCross.mingwW64;
|
||||
mkWin64RustPackage = packageName:
|
||||
let
|
||||
rustTarget = "x86_64-pc-windows-gnu";
|
||||
pkgsWin64 = pkgs.pkgsCross.mingwW64;
|
||||
rustTarget = pkgsWin64.stdenv.hostPlatform.rust.rustcTarget;
|
||||
toolchainWin = fenixPkgs.combine [
|
||||
fenixPkgs.stable.rustc
|
||||
fenixPkgs.stable.cargo
|
||||
@@ -147,6 +125,7 @@
|
||||
|
||||
CARGO_BUILD_TARGET = rustTarget;
|
||||
TARGET_CC = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
|
||||
CFLAGS_x86_64_pc_windows_gnu = "-I${pkgsWin64.windows.pthreads}/include";
|
||||
CARGO_BUILD_RUSTFLAGS = [
|
||||
"-C"
|
||||
"linker=${TARGET_CC}"
|
||||
@@ -158,12 +137,10 @@
|
||||
LD = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
|
||||
};
|
||||
|
||||
pkgsWin32 = pkgs.pkgsCross.mingw32;
|
||||
mkWin32RustPackage = packageName:
|
||||
let
|
||||
rustTarget = "i686-pc-windows-gnu";
|
||||
in
|
||||
let
|
||||
pkgsWin32 = pkgs.pkgsCross.mingw32;
|
||||
rustTarget = pkgsWin32.stdenv.hostPlatform.rust.rustcTarget;
|
||||
toolchainWin = fenixPkgs.combine [
|
||||
fenixPkgs.stable.rustc
|
||||
fenixPkgs.stable.cargo
|
||||
@@ -203,6 +180,7 @@
|
||||
src = pkgs.lib.cleanSource ./.;
|
||||
nativeBuildInputs = [
|
||||
pkgs.perl # Needed to build vendored OpenSSL.
|
||||
pkgs.nasm # aws-lc-sys requires it
|
||||
];
|
||||
depsBuildBuild = [
|
||||
winCC
|
||||
@@ -215,6 +193,7 @@
|
||||
|
||||
CARGO_BUILD_TARGET = rustTarget;
|
||||
TARGET_CC = "${winCC}/bin/${winCC.targetPrefix}cc";
|
||||
CFLAGS_i686_pc_windows_gnu = "-I${pkgsWin32.windows.pthreads}/include";
|
||||
CARGO_BUILD_RUSTFLAGS = [
|
||||
"-C"
|
||||
"linker=${TARGET_CC}"
|
||||
@@ -228,14 +207,12 @@
|
||||
|
||||
mkCrossRustPackage = arch: packageName:
|
||||
let
|
||||
rustTarget = arch2targets."${arch}".rustTarget;
|
||||
crossTarget = arch2targets."${arch}".crossTarget;
|
||||
crossTarget = arch2targets."${arch}";
|
||||
pkgsCross = import nixpkgs {
|
||||
system = system;
|
||||
crossSystem.config = crossTarget;
|
||||
};
|
||||
in
|
||||
let
|
||||
rustTarget = pkgsCross.stdenv.hostPlatform.rust.rustcTarget;
|
||||
toolchain = fenixPkgs.combine [
|
||||
fenixPkgs.stable.rustc
|
||||
fenixPkgs.stable.cargo
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.51.0-dev"
|
||||
version = "2.54.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-05-29
|
||||
2026-06-15
|
||||
42
src/blob.rs
42
src/blob.rs
@@ -1,6 +1,6 @@
|
||||
//! # Blob directory management.
|
||||
|
||||
use std::cmp::max;
|
||||
use std::cmp::{max, min};
|
||||
use std::io::{Cursor, Seek};
|
||||
use std::iter::FusedIterator;
|
||||
use std::mem;
|
||||
@@ -12,7 +12,7 @@ use futures::StreamExt;
|
||||
use image::ImageReader;
|
||||
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
|
||||
use image::{codecs::jpeg::JpegEncoder, metadata::Orientation};
|
||||
use num_traits::FromPrimitive;
|
||||
use num_traits::{FromPrimitive, cast};
|
||||
use tokio::{fs, task};
|
||||
use tokio_stream::wrappers::ReadDirStream;
|
||||
|
||||
@@ -382,14 +382,9 @@ impl<'a> BlobObject<'a> {
|
||||
}
|
||||
img.apply_orientation(orientation);
|
||||
|
||||
// max_wh is the maximum image width and height, i.e. the resolution-limit.
|
||||
// target_wh target-resolution for resizing the image.
|
||||
// max_wh is the maximum image width and height, i.e. the resolution-limit,
|
||||
// as set by `Config::MediaQuality`.
|
||||
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;
|
||||
@@ -428,6 +423,35 @@ impl<'a> BlobObject<'a> {
|
||||
});
|
||||
|
||||
if do_scale {
|
||||
let longest_side_len = max(img.width(), img.height());
|
||||
|
||||
// target_wh will be used as the target-resolution for resizing the image,
|
||||
// so that the longest sides of the image match the target-resolution.
|
||||
let mut target_wh = if !is_avatar {
|
||||
let area_sqrt = (f64::from(img.width()) * f64::from(img.height())).sqrt();
|
||||
// Limit resolution to the number of pixels that fit within max_wh * max_wh,
|
||||
// so that the image-quality does not depend on the aspect-ratio.
|
||||
let mut resolution_limit: u32 = cast(
|
||||
(f64::from(longest_side_len) * (f64::from(max_wh) / area_sqrt)).floor(),
|
||||
)
|
||||
.unwrap_or(max_wh);
|
||||
// Align at least one dimension of the resampled image to a multiple of 8 pixels,
|
||||
// to have fewer partially used JPEG-blocks (which represent 8x8 pixels each).
|
||||
if resolution_limit < longest_side_len && resolution_limit > 8 {
|
||||
while !resolution_limit.is_multiple_of(8) {
|
||||
resolution_limit -= 1
|
||||
}
|
||||
}
|
||||
resolution_limit
|
||||
} else {
|
||||
max_wh
|
||||
};
|
||||
|
||||
target_wh = min(target_wh, longest_side_len);
|
||||
|
||||
// For images in JPEG-format, 65535 pixels is the maximum resolution per dimension.
|
||||
target_wh = min(target_wh, 65535);
|
||||
|
||||
loop {
|
||||
if mem::take(&mut add_white_bg) {
|
||||
self::add_white_bg(&mut img);
|
||||
|
||||
@@ -406,8 +406,8 @@ async fn test_recode_image_balanced_png() {
|
||||
extension: "png",
|
||||
original_width: 1920,
|
||||
original_height: 1080,
|
||||
compressed_width: constants::WORSE_IMAGE_SIZE,
|
||||
compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920,
|
||||
compressed_width: 848,
|
||||
compressed_height: 477,
|
||||
..Default::default()
|
||||
}
|
||||
.test()
|
||||
@@ -497,8 +497,8 @@ async fn test_recode_image_rgba_png_to_jpeg() {
|
||||
extension: "png",
|
||||
original_width: 1920,
|
||||
original_height: 1080,
|
||||
compressed_width: constants::WORSE_IMAGE_SIZE,
|
||||
compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920,
|
||||
compressed_width: 848,
|
||||
compressed_height: 477,
|
||||
..Default::default()
|
||||
}
|
||||
.test()
|
||||
@@ -517,8 +517,8 @@ async fn test_recode_image_huge_jpg() {
|
||||
has_exif: true,
|
||||
original_width: 1920,
|
||||
original_height: 1080,
|
||||
compressed_width: constants::BALANCED_IMAGE_SIZE,
|
||||
compressed_height: constants::BALANCED_IMAGE_SIZE * 1080 / 1920,
|
||||
compressed_width: 1704,
|
||||
compressed_height: 959,
|
||||
..Default::default()
|
||||
}
|
||||
.test()
|
||||
|
||||
80
src/chat.rs
80
src/chat.rs
@@ -50,8 +50,8 @@ use crate::stock_str;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::{
|
||||
IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_secret, create_id,
|
||||
create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
|
||||
gm2local_offset, normalize_text, smeared_time, time, truncate_msg_text,
|
||||
create_outgoing_rfc724_mid, get_abs_path, gm2local_offset, normalize_text, time,
|
||||
truncate_msg_text,
|
||||
};
|
||||
use crate::webxdc::StatusUpdateSerial;
|
||||
|
||||
@@ -292,7 +292,7 @@ impl ChatId {
|
||||
timestamp: i64,
|
||||
) -> Result<Self> {
|
||||
let grpname = sanitize_single_line(grpname);
|
||||
let timestamp = cmp::min(timestamp, smeared_time(context));
|
||||
let timestamp = cmp::min(timestamp, time());
|
||||
let row_id =
|
||||
context.sql.insert(
|
||||
"INSERT INTO chats (type, name, name_normalized, grpid, blocked, created_timestamp, protected, param) VALUES(?, ?, ?, ?, ?, ?, 0, ?)",
|
||||
@@ -1256,7 +1256,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
message_timestamp: i64,
|
||||
always_sort_to_bottom: bool,
|
||||
) -> Result<i64> {
|
||||
let mut sort_timestamp = cmp::min(message_timestamp, smeared_time(context));
|
||||
let mut sort_timestamp = cmp::min(message_timestamp, time());
|
||||
|
||||
let last_msg_time: Option<i64> = if always_sort_to_bottom {
|
||||
// get newest message for this chat
|
||||
@@ -2405,7 +2405,7 @@ impl ChatIdBlocked {
|
||||
_ => (),
|
||||
}
|
||||
|
||||
let smeared_time = create_smeared_timestamp(context);
|
||||
let now = time();
|
||||
|
||||
let chat_id = context
|
||||
.sql
|
||||
@@ -2420,7 +2420,7 @@ impl ChatIdBlocked {
|
||||
normalize_text(&chat_name),
|
||||
params.to_string(),
|
||||
create_blocked as u8,
|
||||
smeared_time,
|
||||
now,
|
||||
),
|
||||
)?;
|
||||
let chat_id = ChatId::new(
|
||||
@@ -2446,7 +2446,7 @@ impl ChatIdBlocked {
|
||||
&& !chat.param.exists(Param::Devicetalk)
|
||||
&& !chat.param.exists(Param::Selftalk)
|
||||
{
|
||||
chat_id.add_e2ee_notice(context, smeared_time).await?;
|
||||
chat_id.add_e2ee_notice(context, now).await?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
@@ -2730,7 +2730,7 @@ async fn prepare_send_msg(
|
||||
}
|
||||
msg.state = MessageState::OutPending;
|
||||
|
||||
msg.timestamp_sort = create_smeared_timestamp(context);
|
||||
msg.timestamp_sort = time();
|
||||
prepare_msg_blob(context, msg).await?;
|
||||
if !msg.hidden {
|
||||
chat_id.unarchive_if_not_muted(context, msg.state).await?;
|
||||
@@ -2924,7 +2924,7 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
);
|
||||
}
|
||||
|
||||
let now = smeared_time(context);
|
||||
let now = time();
|
||||
|
||||
if rendered_msg.last_added_location_id.is_some()
|
||||
&& let Err(err) = location::set_kml_sent_timestamp(context, msg.chat_id, now).await
|
||||
@@ -3566,7 +3566,7 @@ pub(crate) async fn create_group_ex(
|
||||
chat_name = "…".to_string();
|
||||
}
|
||||
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let timestamp = time();
|
||||
let row_id = context
|
||||
.sql
|
||||
.insert(
|
||||
@@ -3649,7 +3649,7 @@ pub(crate) async fn create_out_broadcast_ex(
|
||||
bail!("Invalid broadcast channel name: {chat_name}.");
|
||||
}
|
||||
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let timestamp = time();
|
||||
let trans_fn = |t: &mut rusqlite::Transaction| -> Result<ChatId> {
|
||||
let cnt: u32 = t.query_row(
|
||||
"SELECT COUNT(*) FROM chats WHERE grpid=?",
|
||||
@@ -3738,19 +3738,17 @@ pub(crate) async fn update_chat_contacts_table(
|
||||
id: ChatId,
|
||||
contacts: &BTreeSet<ContactId>,
|
||||
) -> Result<()> {
|
||||
// See add_to_chat_contacts_table() for reasoning.
|
||||
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
|
||||
context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
// Bump `remove_timestamp` even for members from `contacts`.
|
||||
// Bump `remove_timestamp` to at least `now`
|
||||
// even for members from `contacts`.
|
||||
// We add members from `contacts` back below.
|
||||
transaction.execute(
|
||||
"UPDATE chats_contacts SET
|
||||
add_timestamp=MIN(add_timestamp, ?1),
|
||||
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
|
||||
"UPDATE chats_contacts
|
||||
SET remove_timestamp=MAX(add_timestamp+1, ?)
|
||||
WHERE chat_id=?",
|
||||
(limit, timestamp, id),
|
||||
(timestamp, id),
|
||||
)?;
|
||||
|
||||
if !contacts.is_empty() {
|
||||
@@ -3762,8 +3760,9 @@ pub(crate) async fn update_chat_contacts_table(
|
||||
)?;
|
||||
|
||||
for contact_id in contacts {
|
||||
// We bumped `remove_timestamp` for existing rows above,
|
||||
// so on conflict it is enough to set `add_timestamp = remove_timestamp`.
|
||||
// We bumped `add_timestamp` for existing rows above,
|
||||
// so on conflict it is enough to set `add_timestamp = remove_timestamp`
|
||||
// and this guarantees that `add_timestamp` is no less than `timestamp`.
|
||||
statement.execute((id, contact_id, timestamp))?;
|
||||
}
|
||||
}
|
||||
@@ -3780,24 +3779,17 @@ pub(crate) async fn add_to_chat_contacts_table(
|
||||
chat_id: ChatId,
|
||||
contact_ids: &[ContactId],
|
||||
) -> Result<()> {
|
||||
// Our clock may be slow, so limit stored timestamps with `timestamp` if it's bigger. This way
|
||||
// we only cap remote timestamps if, in addition, remote changes arrive reordered or we do local
|
||||
// changes. Also allow some tolerance, moreover, previous removals might lend time from the
|
||||
// future.
|
||||
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
|
||||
context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
let mut add_statement = transaction.prepare(
|
||||
"INSERT INTO chats_contacts (chat_id, contact_id, add_timestamp) VALUES(?1, ?2, ?3)
|
||||
ON CONFLICT (chat_id, contact_id)
|
||||
DO UPDATE SET
|
||||
remove_timestamp=MIN(remove_timestamp, ?4),
|
||||
add_timestamp=MIN(MAX(add_timestamp,remove_timestamp,?3), ?4)",
|
||||
DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)",
|
||||
)?;
|
||||
|
||||
for contact_id in contact_ids {
|
||||
add_statement.execute((chat_id, contact_id, timestamp, limit))?;
|
||||
add_statement.execute((chat_id, contact_id, timestamp))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -3816,16 +3808,13 @@ pub(crate) async fn remove_from_chat_contacts_table(
|
||||
contact_id: ContactId,
|
||||
) -> Result<bool> {
|
||||
let now = time();
|
||||
// See add_to_chat_contacts_table() for reasoning.
|
||||
let limit = now.saturating_add(TIMESTAMP_SENT_TOLERANCE);
|
||||
let is_past_member = context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE chats_contacts SET
|
||||
add_timestamp=MIN(add_timestamp, ?1),
|
||||
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
|
||||
"UPDATE chats_contacts
|
||||
SET remove_timestamp=MAX(add_timestamp+1, ?)
|
||||
WHERE chat_id=? AND contact_id=?",
|
||||
(limit, now, chat_id, contact_id),
|
||||
(now, chat_id, contact_id),
|
||||
)
|
||||
.await?
|
||||
> 0;
|
||||
@@ -3918,11 +3907,11 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
return Ok(false);
|
||||
}
|
||||
if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
|
||||
let smeared_time = smeared_time(context);
|
||||
let now = time();
|
||||
chat.param
|
||||
.remove(Param::Unpromoted)
|
||||
.set_i64(Param::GroupNameTimestamp, smeared_time)
|
||||
.set_i64(Param::GroupDescriptionTimestamp, smeared_time);
|
||||
.set_i64(Param::GroupNameTimestamp, now)
|
||||
.set_i64(Param::GroupDescriptionTimestamp, now);
|
||||
chat.update_param(context).await?;
|
||||
}
|
||||
if context.is_self_addr(contact.get_addr()).await? {
|
||||
@@ -4488,7 +4477,6 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
|
||||
}
|
||||
|
||||
/// Forwards multiple messages to a chat in another context.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub async fn forward_msgs_2ctx(
|
||||
ctx_src: &Context,
|
||||
msg_ids: &[MsgId],
|
||||
@@ -4499,7 +4487,6 @@ pub async fn forward_msgs_2ctx(
|
||||
ensure!(!chat_id.is_special(), "can not forward to special chat");
|
||||
|
||||
let mut created_msgs: Vec<MsgId> = Vec::new();
|
||||
let mut curr_timestamp: i64;
|
||||
|
||||
chat_id
|
||||
.unarchive_if_not_muted(ctx_dst, MessageState::Undefined)
|
||||
@@ -4508,7 +4495,7 @@ pub async fn forward_msgs_2ctx(
|
||||
if let Some(reason) = chat.why_cant_send(ctx_dst).await? {
|
||||
bail!("cannot send to {chat_id}: {reason}");
|
||||
}
|
||||
curr_timestamp = create_smeared_timestamps(ctx_dst, msg_ids.len());
|
||||
let now = time();
|
||||
let mut msgs = Vec::with_capacity(msg_ids.len());
|
||||
for id in msg_ids {
|
||||
let ts: i64 = ctx_src
|
||||
@@ -4573,10 +4560,9 @@ pub async fn forward_msgs_2ctx(
|
||||
msg.state = MessageState::OutPending;
|
||||
msg.rfc724_mid = create_outgoing_rfc724_mid();
|
||||
msg.pre_rfc724_mid.clear();
|
||||
msg.timestamp_sort = curr_timestamp;
|
||||
msg.timestamp_sort = now;
|
||||
chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?;
|
||||
|
||||
curr_timestamp += 1;
|
||||
if !create_send_msg_jobs(ctx_dst, &mut msg).await?.is_empty() {
|
||||
ctx_dst.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
@@ -4669,7 +4655,7 @@ pub(crate) async fn save_copy_in_self_talk(
|
||||
} else {
|
||||
MessageState::InSeen
|
||||
},
|
||||
create_smeared_timestamp(context),
|
||||
time(),
|
||||
msg.param.to_string(),
|
||||
src_msg_id,
|
||||
src_msg_id,
|
||||
@@ -4846,7 +4832,7 @@ pub async fn add_device_msg_with_importance(
|
||||
chat_id = ChatId::get_for_contact(context, ContactId::DEVICE).await?;
|
||||
|
||||
let rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let timestamp_sent = create_smeared_timestamp(context);
|
||||
let timestamp_sent = time();
|
||||
|
||||
// makes sure, the added message is the last one,
|
||||
// even if the date is wrong (useful esp. when warning about bad dates)
|
||||
@@ -4993,7 +4979,7 @@ pub(crate) async fn add_info_msg_with_cmd(
|
||||
} else {
|
||||
let sort_to_bottom = true;
|
||||
chat_id
|
||||
.calc_sort_timestamp(context, smeared_time(context), sort_to_bottom)
|
||||
.calc_sort_timestamp(context, time(), sort_to_bottom)
|
||||
.await?
|
||||
};
|
||||
|
||||
@@ -5156,7 +5142,7 @@ async fn set_contacts_by_fingerprints(
|
||||
Ok(broadcast_contacts_added)
|
||||
})
|
||||
.await?;
|
||||
let timestamp = smeared_time(context);
|
||||
let timestamp = time();
|
||||
for added_id in broadcast_contacts_added {
|
||||
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
|
||||
add_info_msg_with_cmd(
|
||||
|
||||
@@ -1281,7 +1281,7 @@ async fn test_marknoticed_all_chats() -> Result<()> {
|
||||
|
||||
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 {
|
||||
while let Some(sent_msg) = alice.pop_sent_msg_opt().await {
|
||||
let bob_message = bob.recv_msg(&sent_msg).await;
|
||||
let bob_chat_id = bob_message.chat_id;
|
||||
bob_chat_id.accept(bob).await?;
|
||||
@@ -1289,7 +1289,7 @@ async fn test_marknoticed_all_chats() -> Result<()> {
|
||||
}
|
||||
|
||||
tcm.section("alice: receive replies from bob");
|
||||
while let Some(sent_msg) = bob.pop_sent_msg_opt(Duration::default()).await {
|
||||
while let Some(sent_msg) = bob.pop_sent_msg_opt().await {
|
||||
alice.recv_msg(&sent_msg).await;
|
||||
}
|
||||
// ensure chats have unread messages
|
||||
@@ -1633,6 +1633,7 @@ async fn test_set_chat_name() {
|
||||
"another name",
|
||||
"something different",
|
||||
] {
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
set_chat_name(alice, chat_id, new_name).await.unwrap();
|
||||
let sent_msg = alice.pop_sent_msg().await;
|
||||
let received_msg = bob.recv_msg(&sent_msg).await;
|
||||
@@ -2815,7 +2816,7 @@ async fn test_cant_remove_nonmember() -> Result<()> {
|
||||
|
||||
let alice_charlie_id = alice.add_or_lookup_contact_id(charlie).await;
|
||||
remove_contact_from_chat(alice, alice_broadcast_id, alice_charlie_id).await?;
|
||||
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
assert!(alice.pop_sent_msg_opt().await.is_none());
|
||||
assert!(!remove_from_chat_contacts_table(alice, alice_broadcast_id, alice_charlie_id).await?);
|
||||
assert!(
|
||||
!remove_from_chat_contacts_table_without_trace(alice, alice_broadcast_id, alice_charlie_id)
|
||||
@@ -2970,6 +2971,7 @@ async fn test_broadcast_change_name() -> Result<()> {
|
||||
|
||||
{
|
||||
tcm.section("Alice changes the chat name");
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
set_chat_name(alice, broadcast_id, "My great broadcast").await?;
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
|
||||
@@ -2988,6 +2990,7 @@ async fn test_broadcast_change_name() -> Result<()> {
|
||||
|
||||
{
|
||||
tcm.section("Alice changes the chat name again, but the system message is lost somehow");
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
set_chat_name(alice, broadcast_id, "Broadcast channel").await?;
|
||||
|
||||
let chat = Chat::load_from_db(alice, broadcast_id).await?;
|
||||
@@ -3064,13 +3067,11 @@ async fn test_broadcast_resend_to_new_member() -> Result<()> {
|
||||
}
|
||||
for i in 0..N_MSGS_TO_NEW_BROADCAST_MEMBER {
|
||||
let rev_order = false;
|
||||
let resent_msg = alice
|
||||
.pop_sent_msg_ex(rev_order, Duration::ZERO)
|
||||
.await
|
||||
.unwrap();
|
||||
let resent_msg = alice.pop_sent_msg_ex(rev_order).await.unwrap();
|
||||
let fiona_msg = fiona.recv_msg(&resent_msg).await;
|
||||
assert_eq!(fiona_msg.chat_id, fiona_bc_id);
|
||||
assert_eq!(fiona_msg.text, (i + 1).to_string());
|
||||
assert_eq!(fiona_msg.param.get_bool(Param::WantsMdn).unwrap(), true);
|
||||
assert!(resent_msg.recipients.contains("fiona@example.net"));
|
||||
assert!(!resent_msg.recipients.contains("bob@"));
|
||||
// The message is undecryptable for Bob, he mustn't be able to know yet that somebody joined
|
||||
@@ -3084,7 +3085,7 @@ async fn test_broadcast_resend_to_new_member() -> Result<()> {
|
||||
);
|
||||
bob.recv_msg_trash(&resent_msg).await;
|
||||
}
|
||||
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
assert!(alice.pop_sent_msg_opt().await.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3342,6 +3343,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
assert_eq!(bob_chat.get_profile_image(bob).await?, None);
|
||||
|
||||
tcm.section("Change broadcast channel name, and check that receivers see it");
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
set_chat_name(alice, alice_chat_id, "New Channel name").await?;
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
@@ -3507,8 +3509,9 @@ async fn test_chat_description(
|
||||
"",
|
||||
"ä ẟ 😂",
|
||||
] {
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
tcm.section(&format!(
|
||||
"Alice sets the chat description to '{description}'"
|
||||
"Alice sets the chat description to {description:?}"
|
||||
));
|
||||
set_chat_description(alice, alice_chat_id, description).await?;
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
@@ -3547,12 +3550,7 @@ async fn test_chat_description(
|
||||
|
||||
tcm.section("Alice calls set_chat_description() without actually changing the description");
|
||||
set_chat_description(alice, alice_chat_id, "ä ẟ 😂").await?;
|
||||
assert!(
|
||||
alice
|
||||
.pop_sent_msg_opt(Duration::from_secs(0))
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(alice.pop_sent_msg_opt().await.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3577,12 +3575,7 @@ async fn test_setting_empty_chat_description() -> Result<()> {
|
||||
let _hi = alice.send_text(alice_chat_id, "hi").await;
|
||||
|
||||
set_chat_description(alice, alice_chat_id, "").await?;
|
||||
assert!(
|
||||
alice
|
||||
.pop_sent_msg_opt(Duration::from_secs(0))
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(alice.pop_sent_msg_opt().await.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4529,7 +4522,9 @@ async fn test_get_chat_media_webxdc_order() -> Result<()> {
|
||||
assert_eq!(media.first().unwrap(), &instance1_id);
|
||||
assert_eq!(media.get(1).unwrap(), &instance2_id);
|
||||
|
||||
// add a status update for the oder instance; that resorts the list
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
|
||||
// add a status update for the other instance; that resorts the list
|
||||
alice
|
||||
.send_webxdc_status_update(instance1_id, r#"{"payload": {"foo": "bar"}}"#)
|
||||
.await?;
|
||||
@@ -4945,10 +4940,6 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
|
||||
vec![a2b_contact_id]
|
||||
);
|
||||
|
||||
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
|
||||
// before "You joined the channel." for bob. alice1 makes 3 more calls of
|
||||
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
|
||||
SystemTime::shift(Duration::from_secs(3));
|
||||
tcm.section("Alice's second device sends a message to the channel");
|
||||
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
|
||||
let msg = bob.recv_msg(&sent_msg).await;
|
||||
@@ -5099,6 +5090,80 @@ async fn test_broadcast_contacts_are_hidden() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_blocked_bob_cant_join_chat() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice1 = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
for a in [alice1, alice2] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
// The observing device has Bob blocked from the early start.
|
||||
let alice2_bob_id = alice2.add_or_lookup_contact_id(bob).await;
|
||||
Contact::block(alice2, alice2_bob_id).await?;
|
||||
|
||||
let alice1_chat_id = create_group(alice1, "").await?;
|
||||
sync(alice1, alice2).await;
|
||||
let alice1_chat = Chat::load_from_db(alice1, alice1_chat_id).await?;
|
||||
let (alice2_chat_id, _blocked) = get_chat_id_by_grpid(alice2, &alice1_chat.grpid)
|
||||
.await?
|
||||
.unwrap();
|
||||
let qr = get_securejoin_qr(alice1, Some(alice1_chat_id)).await?;
|
||||
sync(alice1, alice2).await;
|
||||
|
||||
tcm.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr)
|
||||
.await;
|
||||
let alice1_bob_id = alice1.add_or_lookup_contact_id(bob).await;
|
||||
assert_eq!(get_chat_contacts(alice1, alice1_chat_id).await?.len(), 2);
|
||||
// "vg-member-added" from alice1 adds bob for alice2 to provide membership consistency on
|
||||
// devices.
|
||||
assert_eq!(get_chat_contacts(alice2, alice2_chat_id).await?.len(), 2);
|
||||
remove_contact_from_chat(alice1, alice1_chat_id, alice1_bob_id).await?;
|
||||
bob.recv_msg(&alice1.pop_sent_msg().await).await;
|
||||
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
|
||||
// Bob can join again if he isn't blocked.
|
||||
assert_eq!(get_chat_contacts(alice1, alice1_chat_id).await?.len(), 2);
|
||||
Contact::block(alice1, alice1_bob_id).await?;
|
||||
remove_contact_from_chat(alice1, alice1_chat_id, alice1_bob_id).await?;
|
||||
bob.recv_msg(&alice1.pop_sent_msg().await).await;
|
||||
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
|
||||
let members = get_chat_contacts(alice1, alice1_chat_id).await?;
|
||||
assert_eq!(members.len(), 1);
|
||||
assert!(members.contains(&ContactId::SELF));
|
||||
let past_members = get_past_chat_contacts(alice1, alice1_chat_id).await?;
|
||||
assert_eq!(past_members.len(), 1);
|
||||
assert!(past_members.contains(&alice1_bob_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_blocked_bob_cant_create_11_chat_via_securejoin() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice1 = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
for a in [alice1, alice2] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
// The observing device has Bob blocked.
|
||||
let alice2_bob_id = alice2.add_or_lookup_contact_id(bob).await;
|
||||
Contact::block(alice2, alice2_bob_id).await?;
|
||||
|
||||
let qr = get_securejoin_qr(alice1, None).await?;
|
||||
sync(alice1, alice2).await;
|
||||
|
||||
let chat_cnt = get_chat_cnt(alice1).await?;
|
||||
assert_eq!(get_chat_cnt(alice2).await?, chat_cnt);
|
||||
tcm.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr)
|
||||
.await;
|
||||
assert_eq!(get_chat_cnt(alice1).await?, chat_cnt + 1);
|
||||
assert_eq!(get_chat_cnt(alice2).await?, chat_cnt);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests sending JPEG image with .png extension.
|
||||
///
|
||||
/// This is a regression test, previously sending failed
|
||||
|
||||
@@ -49,6 +49,21 @@ use crate::{chat, provider};
|
||||
/// see <https://github.com/chatmail/core/issues/7608>
|
||||
pub(crate) const MAX_TRANSPORT_RELAYS: usize = 5;
|
||||
|
||||
/// Hard-coded candidates for default relays.
|
||||
/// In the future, we want to use it during onboarding;
|
||||
/// note that before onboarding automatically on any of these,
|
||||
/// we need to ask the admins whether their relay is able to handle this.
|
||||
/// For now, this is just the first 6 relays from chatmail.at/relays.
|
||||
#[allow(unused)]
|
||||
const DEFAULT_RELAY_CANDIDATES: &[&str] = &[
|
||||
"mehl.cloud",
|
||||
"mailchat.pl",
|
||||
"chatmail.woodpeckersnest.space",
|
||||
"chatmail.culturanerd.it",
|
||||
"tarpit.fun",
|
||||
"d.gaufr.es",
|
||||
];
|
||||
|
||||
macro_rules! progress {
|
||||
($context:tt, $progress:expr, $comment:expr) => {
|
||||
assert!(
|
||||
|
||||
@@ -31,7 +31,6 @@ use crate::quota::QuotaInfo;
|
||||
use crate::scheduler::{ConnectivityStore, SchedulerState};
|
||||
use crate::sql::Sql;
|
||||
use crate::stock_str::StockStrings;
|
||||
use crate::timesmearing::SmearedTimestamp;
|
||||
use crate::tools::{self, duration_to_str, time, time_elapsed};
|
||||
use crate::transport::ConfiguredLoginParam;
|
||||
use crate::{chatlist_events, stats};
|
||||
@@ -227,7 +226,6 @@ pub struct InnerContext {
|
||||
/// Blob directory path
|
||||
pub(crate) blobdir: PathBuf,
|
||||
pub(crate) sql: Sql,
|
||||
pub(crate) smeared_timestamp: SmearedTimestamp,
|
||||
/// The global "ongoing" process state.
|
||||
///
|
||||
/// This is a global mutex-like state for operations which should be modal in the
|
||||
@@ -486,7 +484,6 @@ impl Context {
|
||||
blobdir,
|
||||
running_state: RwLock::new(Default::default()),
|
||||
sql: Sql::new(dbfile),
|
||||
smeared_timestamp: SmearedTimestamp::new(),
|
||||
oauth2_mutex: Mutex::new(()),
|
||||
wrong_pw_warning_mutex: Mutex::new(()),
|
||||
housekeeping_mutex: Mutex::new(()),
|
||||
|
||||
@@ -25,9 +25,10 @@ impl EncryptHelper {
|
||||
}
|
||||
|
||||
pub fn get_aheader(&self) -> Aheader {
|
||||
let public_key = pgp::minimize_autocrypt_certificate(&self.public_key);
|
||||
Aheader {
|
||||
addr: self.addr.clone(),
|
||||
public_key: self.public_key.clone(),
|
||||
public_key,
|
||||
prefer_encrypt: EncryptPreference::Mutual,
|
||||
verified: false,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::message::markseen_msgs;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE;
|
||||
use crate::{
|
||||
chat::{self, Chat, ChatItem, create_group, send_text_msg},
|
||||
tools::IsNoneOrEmpty,
|
||||
@@ -172,7 +171,7 @@ async fn test_ephemeral_unpromoted() -> Result<()> {
|
||||
chat_id
|
||||
.set_ephemeral_timer(&alice, Timer::Enabled { duration: 60 })
|
||||
.await?;
|
||||
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
|
||||
let sent = alice.pop_sent_msg_opt().await;
|
||||
assert!(sent.is_none());
|
||||
assert_eq!(
|
||||
chat_id.get_ephemeral_timer(&alice).await?,
|
||||
@@ -182,13 +181,13 @@ async fn test_ephemeral_unpromoted() -> Result<()> {
|
||||
// Promote the group.
|
||||
send_text_msg(&alice, chat_id, "hi!".to_string()).await?;
|
||||
assert!(chat_id.is_promoted(&alice).await?);
|
||||
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
|
||||
let sent = alice.pop_sent_msg_opt().await;
|
||||
assert!(sent.is_some());
|
||||
|
||||
chat_id
|
||||
.set_ephemeral_timer(&alice.ctx, Timer::Disabled)
|
||||
.await?;
|
||||
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
|
||||
let sent = alice.pop_sent_msg_opt().await;
|
||||
assert!(sent.is_some());
|
||||
assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled);
|
||||
|
||||
@@ -355,17 +354,9 @@ async fn test_ephemeral_delete_msgs() -> Result<()> {
|
||||
let now = time();
|
||||
let msg = t.send_text(bob_chat.id, "Message text").await;
|
||||
|
||||
check_msg_will_be_deleted(
|
||||
t,
|
||||
msg.sender_msg_id,
|
||||
&bob_chat,
|
||||
now + 1799,
|
||||
// The message may appear to be sent MAX_SECONDS_TO_LEND_FROM_FUTURE later and
|
||||
// therefore be deleted MAX_SECONDS_TO_LEND_FROM_FUTURE later.
|
||||
time() + 1801 + MAX_SECONDS_TO_LEND_FROM_FUTURE,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
check_msg_will_be_deleted(t, msg.sender_msg_id, &bob_chat, now + 1799, time() + 1801)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Enable ephemeral messages with Bob -> message will be deleted after 60s.
|
||||
// This tests that the message is deleted at min(ephemeral deletion time, DeleteDeviceAfter deletion time).
|
||||
|
||||
@@ -181,6 +181,17 @@ pub enum EventType {
|
||||
msg_id: MsgId,
|
||||
},
|
||||
|
||||
/// Like [`EventType::MsgRead`], but also fires on subsequent MDNs,
|
||||
/// if there are multiple receivers, i.e. in groups and channels.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MsgReadCountChanged {
|
||||
/// ID of the chat which the message belongs to.
|
||||
chat_id: ChatId,
|
||||
|
||||
/// ID of the message that was read.
|
||||
msg_id: MsgId,
|
||||
},
|
||||
|
||||
/// A single message was deleted.
|
||||
///
|
||||
/// This event means that the message will no longer appear in the messagelist.
|
||||
|
||||
85
src/html.rs
85
src/html.rs
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::mem;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use base64::Engine as _;
|
||||
use mailparse::ParsedContentType;
|
||||
use mime::Mime;
|
||||
@@ -17,10 +17,12 @@ use mime::Mime;
|
||||
use crate::context::Context;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::log::warn;
|
||||
use crate::message::{self, Message, MsgId};
|
||||
use crate::message::{Message, MsgId};
|
||||
use crate::mimeparser::parse_message_id;
|
||||
use crate::param::Param::SendHtml;
|
||||
use crate::param::{Param::SendHtml, Params};
|
||||
use crate::plaintext::PlainText;
|
||||
use crate::sql;
|
||||
use crate::tools::{buf_compress, buf_decompress};
|
||||
|
||||
impl Message {
|
||||
/// Check if the message can be retrieved as HTML.
|
||||
@@ -258,28 +260,71 @@ impl MsgId {
|
||||
/// 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>> {
|
||||
// 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) {
|
||||
let (param, headers, compressed) = context
|
||||
.sql
|
||||
.query_row(
|
||||
"SELECT param, mime_headers, mime_compressed FROM msgs WHERE id=?",
|
||||
(self,),
|
||||
|row| {
|
||||
let param: String = row.get(0)?;
|
||||
let param: Params = param.parse().unwrap_or_default();
|
||||
let headers = sql::row_get_vec(row, 1)?;
|
||||
let compressed: bool = row.get(2)?;
|
||||
Ok((param, headers, compressed))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
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) {
|
||||
Err(err) => {
|
||||
warn!(context, "get_html: parser error: {:#}", err);
|
||||
Ok(None)
|
||||
let from_rawmime = |rawmime: Vec<u8>| {
|
||||
if !rawmime.is_empty() {
|
||||
match HtmlMsgParser::from_bytes(context, &rawmime) {
|
||||
Err(err) => {
|
||||
warn!(context, "get_html: parser error: {:#}", err);
|
||||
Ok(None)
|
||||
}
|
||||
Ok((parser, _)) => Ok(Some(parser.html)),
|
||||
}
|
||||
Ok((parser, _)) => Ok(Some(parser.html)),
|
||||
} else {
|
||||
warn!(context, "get_html: no mime for {}", self);
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
warn!(context, "get_html: no mime for {}", self);
|
||||
Ok(None)
|
||||
};
|
||||
|
||||
if compressed {
|
||||
return from_rawmime(buf_decompress(&headers)?);
|
||||
}
|
||||
let headers2 = headers.clone();
|
||||
let compressed = match tokio::task::block_in_place(move || buf_compress(&headers2)) {
|
||||
Err(e) => {
|
||||
warn!(context, "get_mime_headers: buf_compress() failed: {}", e);
|
||||
return from_rawmime(headers);
|
||||
}
|
||||
Ok(o) => o,
|
||||
};
|
||||
let update = |conn: &mut rusqlite::Connection| {
|
||||
match conn.execute(
|
||||
"
|
||||
UPDATE msgs SET mime_headers=?, mime_compressed=1
|
||||
WHERE id=? AND mime_headers!='' AND mime_compressed=0",
|
||||
(compressed, self),
|
||||
) {
|
||||
Ok(rows_updated) => ensure!(rows_updated <= 1),
|
||||
Err(e) => {
|
||||
warn!(context, "get_mime_headers: UPDATE failed: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = context.sql.call_write(update).await {
|
||||
warn!(
|
||||
context,
|
||||
"get_mime_headers: failed to update mime_headers: {}", e
|
||||
);
|
||||
}
|
||||
from_rawmime(headers)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,13 +124,11 @@ pub trait DcKey: Serialize + Deserializable + Clone {
|
||||
|
||||
/// Converts secret key to public key.
|
||||
pub(crate) fn secret_key_to_public_key(
|
||||
context: &Context,
|
||||
mut signed_secret_key: SignedSecretKey,
|
||||
timestamp: u32,
|
||||
addr: &str,
|
||||
relay_addrs: &str,
|
||||
) -> Result<SignedPublicKey> {
|
||||
info!(context, "Converting secret key to public key.");
|
||||
let timestamp = pgp::types::Timestamp::from_secs(timestamp);
|
||||
|
||||
// Subpackets that we want to share between DKS and User ID signature.
|
||||
@@ -298,7 +296,7 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
|
||||
let addr = context.get_primary_self_addr().await?;
|
||||
let all_addrs = context.get_published_self_addrs().await?.join(",");
|
||||
let signed_public_key =
|
||||
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
|
||||
secret_key_to_public_key(signed_secret_key, timestamp, &addr, &all_addrs)?;
|
||||
*lock = Some(signed_public_key.clone());
|
||||
|
||||
Ok(Some(signed_public_key))
|
||||
|
||||
@@ -94,7 +94,6 @@ mod smtp;
|
||||
pub mod stock_str;
|
||||
pub mod storage_usage;
|
||||
mod sync;
|
||||
mod timesmearing;
|
||||
mod token;
|
||||
mod transport;
|
||||
mod update_helper;
|
||||
|
||||
@@ -30,13 +30,12 @@ use crate::log::warn;
|
||||
use crate::mimeparser::{SystemMessage, parse_message_id};
|
||||
use crate::param::{Param, Params};
|
||||
use crate::reaction::get_msg_reactions;
|
||||
use crate::sql;
|
||||
use crate::summary::Summary;
|
||||
use crate::sync::SyncData;
|
||||
use crate::tools::create_outgoing_rfc724_mid;
|
||||
use crate::tools::{
|
||||
buf_compress, buf_decompress, get_filebytes, get_filemeta, gm2local_offset, read_file,
|
||||
sanitize_filename, time, timestamp_to_str,
|
||||
get_filebytes, get_filemeta, gm2local_offset, read_file, sanitize_filename, time,
|
||||
timestamp_to_str,
|
||||
};
|
||||
|
||||
/// Message ID, including reserved IDs.
|
||||
@@ -1624,62 +1623,6 @@ pub(crate) fn guess_msgtype_from_path_suffix(path: &Path) -> Option<(Viewtype, &
|
||||
Some(info)
|
||||
}
|
||||
|
||||
/// Get the raw mime-headers of the given message.
|
||||
/// Raw headers are saved for large messages
|
||||
/// that need a "Show full message..."
|
||||
/// to see HTML part.
|
||||
///
|
||||
/// Returns an empty vector if there are no headers saved for the given message.
|
||||
pub(crate) async fn get_mime_headers(context: &Context, msg_id: MsgId) -> Result<Vec<u8>> {
|
||||
let (headers, compressed) = context
|
||||
.sql
|
||||
.query_row(
|
||||
"SELECT mime_headers, mime_compressed FROM msgs WHERE id=?",
|
||||
(msg_id,),
|
||||
|row| {
|
||||
let headers = sql::row_get_vec(row, 0)?;
|
||||
let compressed: bool = row.get(1)?;
|
||||
Ok((headers, compressed))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if compressed {
|
||||
return buf_decompress(&headers);
|
||||
}
|
||||
|
||||
let headers2 = headers.clone();
|
||||
let compressed = match tokio::task::block_in_place(move || buf_compress(&headers2)) {
|
||||
Err(e) => {
|
||||
warn!(context, "get_mime_headers: buf_compress() failed: {}", e);
|
||||
return Ok(headers);
|
||||
}
|
||||
Ok(o) => o,
|
||||
};
|
||||
let update = |conn: &mut rusqlite::Connection| {
|
||||
match conn.execute(
|
||||
"\
|
||||
UPDATE msgs SET mime_headers=?, mime_compressed=1 \
|
||||
WHERE id=? AND mime_headers!='' AND mime_compressed=0",
|
||||
(compressed, msg_id),
|
||||
) {
|
||||
Ok(rows_updated) => ensure!(rows_updated <= 1),
|
||||
Err(e) => {
|
||||
warn!(context, "get_mime_headers: UPDATE failed: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = context.sql.call_write(update).await {
|
||||
warn!(
|
||||
context,
|
||||
"get_mime_headers: failed to update mime_headers: {}", e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// Delete a single message from the database, including references in other tables.
|
||||
/// This may be called in batches; the final events are emitted in delete_msgs_locally_done() then.
|
||||
pub(crate) async fn delete_msg_locally(context: &Context, msg: &Message) -> Result<()> {
|
||||
|
||||
@@ -35,10 +35,7 @@ use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
|
||||
use crate::pgp::{SeipdVersion, addresses_from_public_key, pubkey_supports_seipdv2};
|
||||
use crate::simplify::escape_message_footer_marks;
|
||||
use crate::stock_str;
|
||||
use crate::tools::{
|
||||
IsNoneOrEmpty, create_outgoing_rfc724_mid, create_smeared_timestamp, remove_subject_prefix,
|
||||
time,
|
||||
};
|
||||
use crate::tools::{IsNoneOrEmpty, create_outgoing_rfc724_mid, remove_subject_prefix, time};
|
||||
use crate::webxdc::StatusUpdateSerial;
|
||||
|
||||
// attachments of 25 mb brutto should work on the majority of providers
|
||||
@@ -224,7 +221,10 @@ impl MimeFactory {
|
||||
let mut member_fingerprints = Vec::new();
|
||||
let mut member_timestamps = Vec::new();
|
||||
let mut recipient_ids = HashSet::new();
|
||||
let mut req_mdn = false;
|
||||
let req_mdn = !chat.is_self_talk()
|
||||
&& !msg.is_system_message()
|
||||
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
|
||||
&& context.should_request_mdns().await?;
|
||||
|
||||
let encryption_pubkeys;
|
||||
|
||||
@@ -481,13 +481,6 @@ impl MimeFactory {
|
||||
ContactId::scaleup_origin(context, &recipient_ids, origin).await?;
|
||||
}
|
||||
|
||||
if !msg.is_system_message()
|
||||
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
|
||||
&& context.should_request_mdns().await?
|
||||
{
|
||||
req_mdn = true;
|
||||
}
|
||||
|
||||
encryption_pubkeys = if !is_encrypted {
|
||||
None
|
||||
} else if should_encrypt_symmetrically(&msg, &chat) {
|
||||
@@ -580,7 +573,7 @@ impl MimeFactory {
|
||||
) -> Result<MimeFactory> {
|
||||
let contact = Contact::get_by_id(context, from_id).await?;
|
||||
let from_addr = context.get_primary_self_addr().await?;
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let timestamp = time();
|
||||
|
||||
let addr = contact.get_addr().to_string();
|
||||
let encryption_pubkeys = if from_id == ContactId::SELF {
|
||||
@@ -1106,9 +1099,11 @@ impl MimeFactory {
|
||||
continue;
|
||||
}
|
||||
|
||||
let public_key = crate::pgp::minimize_autocrypt_certificate(&key);
|
||||
|
||||
let header = Aheader {
|
||||
addr: addr.clone(),
|
||||
public_key: key.clone(),
|
||||
public_key,
|
||||
// Autocrypt 1.1.0 specification says that
|
||||
// `prefer-encrypt` attribute SHOULD NOT be included.
|
||||
prefer_encrypt: EncryptPreference::NoPreference,
|
||||
@@ -1813,14 +1808,15 @@ impl MimeFactory {
|
||||
HeaderDef::IrohGossipTopic.get_headername(),
|
||||
mail_builder::headers::raw::Raw::new(topic).into(),
|
||||
));
|
||||
if let (Some(json), _) = context
|
||||
.render_webxdc_status_update_object(
|
||||
msg.id,
|
||||
StatusUpdateSerial::MIN,
|
||||
StatusUpdateSerial::MAX,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
if !matches!(self.pre_message_mode, PreMessageMode::Pre { .. })
|
||||
&& let (Some(json), _) = context
|
||||
.render_webxdc_status_update_object(
|
||||
msg.id,
|
||||
StatusUpdateSerial::MIN,
|
||||
StatusUpdateSerial::MAX,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
parts.push(context.build_status_update_part(&json));
|
||||
}
|
||||
@@ -2276,7 +2272,7 @@ pub(crate) async fn render_symm_encrypted_securejoin_message(
|
||||
mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(),
|
||||
));
|
||||
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let timestamp = time();
|
||||
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
|
||||
.unwrap()
|
||||
.to_rfc2822();
|
||||
|
||||
@@ -31,9 +31,7 @@ use crate::message::{self, Message, MsgId, Viewtype, get_vcard_summary, set_msg_
|
||||
use crate::param::{Param, Params};
|
||||
use crate::simplify::{SimplifiedText, simplify};
|
||||
use crate::sync::SyncItems;
|
||||
use crate::tools::{
|
||||
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
|
||||
};
|
||||
use crate::tools::{get_filemeta, parse_receive_headers, time, truncate_msg_text, validate_id};
|
||||
use crate::{chatlist_events, location, tools};
|
||||
|
||||
/// Public key extracted from `Autocrypt-Gossip`
|
||||
@@ -271,7 +269,7 @@ impl MimeMessage {
|
||||
pub(crate) async fn from_bytes(context: &Context, body: &[u8]) -> Result<Self> {
|
||||
let mail = mailparse::parse_mail(body)?;
|
||||
|
||||
let timestamp_rcvd = smeared_time(context);
|
||||
let timestamp_rcvd = time();
|
||||
let mut timestamp_sent =
|
||||
Self::get_timestamp_sent(&mail.headers, timestamp_rcvd, timestamp_rcvd);
|
||||
let hop_info = parse_receive_headers(&mail.get_headers());
|
||||
@@ -2509,6 +2507,7 @@ async fn handle_mdn(
|
||||
// note(treefit): only matters if it is the last message in chat (but probably too expensive to check, debounce also solves it)
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat_id);
|
||||
}
|
||||
context.emit_event(EventType::MsgReadCountChanged { chat_id, msg_id });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use mailparse::ParsedMail;
|
||||
use std::mem;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
@@ -1435,7 +1434,7 @@ async fn test_intended_recipient_fingerprint() -> Result<()> {
|
||||
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());
|
||||
assert!(t.pop_sent_msg_opt().await.is_none());
|
||||
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
let contact = t.add_or_lookup_contact(member).await;
|
||||
|
||||
276
src/pgp.rs
276
src/pgp.rs
@@ -1,6 +1,8 @@
|
||||
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::btree_map::Entry as BTreeMapEntry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
@@ -14,7 +16,7 @@ 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::{Signature, Subpacket, SubpacketData};
|
||||
use pgp::packet::{Signature, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{
|
||||
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
|
||||
StringToKey,
|
||||
@@ -83,13 +85,63 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
|
||||
|
||||
/// Selects a subkey of the public key to use for encryption.
|
||||
///
|
||||
/// Returns `None` if the public key cannot be used for encryption.
|
||||
/// The key is selected according to
|
||||
/// <https://www.ietf.org/archive/id/draft-autocrypt-openpgp-v2-cert-02.html#section-4.3-4>.
|
||||
/// If multiple keys are available, the one that will expire sooner is selected.
|
||||
///
|
||||
/// TODO: take key flags and expiration dates into account
|
||||
fn select_pk_for_encryption(key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
|
||||
/// Returns `None` if the public key cannot be used for encryption.
|
||||
fn select_pk_for_encryption(now: u32, key: &SignedPublicKey) -> Option<&SignedPublicSubKey> {
|
||||
key.public_subkeys
|
||||
.iter()
|
||||
.find(|subkey| subkey.algorithm().can_encrypt())
|
||||
.filter(|subkey| subkey.algorithm().can_encrypt())
|
||||
.filter_map(|subkey| {
|
||||
// TODO: deal with multiple signatures.
|
||||
let signature = subkey.signatures.first()?;
|
||||
|
||||
let key_flags = signature.key_flags();
|
||||
if !key_flags.encrypt_comms() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(expiration_duration) = signature
|
||||
.key_expiration_time()
|
||||
.filter(|duration| duration.as_secs() != 0)
|
||||
&& now
|
||||
> subkey
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.saturating_add(expiration_duration.as_secs())
|
||||
{
|
||||
// Key is expired.
|
||||
return None;
|
||||
}
|
||||
Some((subkey, signature))
|
||||
})
|
||||
.min_by(|(subkey1, signature1), (subkey2, signature2)| {
|
||||
match (
|
||||
signature1
|
||||
.key_expiration_time()
|
||||
.filter(|duration| duration.as_secs() != 0),
|
||||
signature2
|
||||
.key_expiration_time()
|
||||
.filter(|duration| duration.as_secs() != 0),
|
||||
) {
|
||||
(None, None) => Ordering::Equal,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(Some(expiration1), Some(expiration2)) => (subkey1
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.saturating_add(expiration1.as_secs()))
|
||||
.cmp(
|
||||
&(subkey2
|
||||
.created_at()
|
||||
.as_secs()
|
||||
.saturating_add(expiration2.as_secs())),
|
||||
),
|
||||
}
|
||||
})
|
||||
.map(|(subkey, _signature)| subkey)
|
||||
}
|
||||
|
||||
/// Version of SEIPD packet to use.
|
||||
@@ -119,10 +171,11 @@ pub async fn pk_encrypt(
|
||||
Handle::current()
|
||||
.spawn_blocking(move || {
|
||||
let mut rng = thread_rng();
|
||||
let now = pgp::types::Timestamp::now();
|
||||
|
||||
let pkeys = public_keys_for_encryption
|
||||
.iter()
|
||||
.filter_map(select_pk_for_encryption);
|
||||
.filter_map(|key| select_pk_for_encryption(now.as_secs(), key));
|
||||
let subpkts = {
|
||||
let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1);
|
||||
hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime(
|
||||
@@ -294,6 +347,163 @@ pub async fn symm_encrypt_message(
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Minimizes the signatures of a subkey.
|
||||
///
|
||||
/// Keeps at most one subkey binding signature
|
||||
/// and at most one revocation signature,
|
||||
/// preferring the newest signatures.
|
||||
///
|
||||
/// Subkey binding signature is kept
|
||||
/// even if the revocation signature exists
|
||||
/// because according to
|
||||
/// <https://www.rfc-editor.org/rfc/rfc9580.html#name-openpgp-version-6-certifica>
|
||||
/// "Every subkey MUST have at least one Subkey Binding signature."
|
||||
/// Distributing subkey with only a revocation signature
|
||||
/// is not allowed according to the standard,
|
||||
/// so we keep a subkey binding signature next to it
|
||||
/// for interoperability.
|
||||
///
|
||||
/// This function does not check if the signatures are valid.
|
||||
/// Such properties should be validated when importing OpenPGP certificates.
|
||||
fn minimize_subpacket_signatures(signatures: Vec<Signature>) -> Vec<Signature> {
|
||||
let mut newest_revocation_signature: Option<Signature> = None;
|
||||
let mut newest_binding_signature: Option<Signature> = None;
|
||||
for signature in signatures {
|
||||
let Some(config) = signature.config() else {
|
||||
// Skip unknown signatures.
|
||||
continue;
|
||||
};
|
||||
match config.typ {
|
||||
SignatureType::SubkeyBinding => {
|
||||
if newest_binding_signature.as_ref().is_none_or(|s| {
|
||||
s.created().map(|ts| ts.as_secs()) < signature.created().map(|ts| ts.as_secs())
|
||||
}) {
|
||||
newest_binding_signature = Some(signature)
|
||||
}
|
||||
}
|
||||
SignatureType::SubkeyRevocation => {
|
||||
if newest_revocation_signature.as_ref().is_none_or(|s| {
|
||||
s.created().map(|ts| ts.as_secs()) < signature.created().map(|ts| ts.as_secs())
|
||||
}) {
|
||||
newest_revocation_signature = Some(signature)
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
newest_revocation_signature
|
||||
.into_iter()
|
||||
.chain(newest_binding_signature)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Minimizes OpenPGP certificate for Autocrypt and Autocrypt-Gossip headers.
|
||||
pub fn minimize_autocrypt_certificate(certificate: &SignedPublicKey) -> SignedPublicKey {
|
||||
let primary_key = certificate.primary_key.clone();
|
||||
let details = certificate.details.clone();
|
||||
|
||||
// Select the newest non-expiring subkey and the newest expiring subkey.
|
||||
let fallback_subkey = certificate
|
||||
.public_subkeys
|
||||
.iter()
|
||||
.filter(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.is_some_and(|signature| signature.key_expiration_time().is_none())
|
||||
})
|
||||
.max_by_key(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.map_or(0, |signature| {
|
||||
signature.created().unwrap_or(subkey.created_at()).as_secs()
|
||||
})
|
||||
});
|
||||
let rotating_subkey = certificate
|
||||
.public_subkeys
|
||||
.iter()
|
||||
.filter(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.is_some_and(|signature| signature.key_expiration_time().is_some())
|
||||
})
|
||||
.max_by_key(|subkey| {
|
||||
subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})
|
||||
.map_or(0, |signature| {
|
||||
signature.created().unwrap_or(subkey.created_at()).as_secs()
|
||||
})
|
||||
});
|
||||
let public_subkeys: Vec<_> = fallback_subkey
|
||||
.into_iter()
|
||||
.chain(rotating_subkey)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// We do not want to ever gossip more than two subkeys
|
||||
// to save the traffic.
|
||||
debug_assert!(public_subkeys.len() <= 2);
|
||||
|
||||
SignedPublicKey {
|
||||
primary_key,
|
||||
details,
|
||||
public_subkeys,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges two OpenPGP subkeys.
|
||||
fn merge_openpgp_subkey(old_subkey: &mut SignedPublicSubKey, new_subkey: SignedPublicSubKey) {
|
||||
debug_assert_eq!(old_subkey.fingerprint(), new_subkey.fingerprint());
|
||||
old_subkey.signatures = minimize_subpacket_signatures(
|
||||
std::mem::take(&mut old_subkey.signatures)
|
||||
.into_iter()
|
||||
.chain(new_subkey.signatures)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Merges OpenPGP subkey vectors.
|
||||
pub fn merge_openpgp_subkeys(
|
||||
subkeys: impl IntoIterator<Item = SignedPublicSubKey>,
|
||||
) -> Result<Vec<SignedPublicSubKey>> {
|
||||
let mut merged_subkeys: BTreeMap<_, SignedPublicSubKey> = BTreeMap::new();
|
||||
for subkey in subkeys {
|
||||
let imprint = subkey.imprint::<Sha256>()?;
|
||||
match merged_subkeys.entry(imprint) {
|
||||
BTreeMapEntry::Vacant(entry) => {
|
||||
entry.insert(subkey);
|
||||
}
|
||||
BTreeMapEntry::Occupied(entry) => {
|
||||
merge_openpgp_subkey(entry.into_mut(), subkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(merged_subkeys.into_values().collect())
|
||||
}
|
||||
|
||||
/// Merges and minimizes OpenPGP certificates.
|
||||
///
|
||||
/// Keeps at most one direct key signature and
|
||||
@@ -330,7 +540,7 @@ pub fn merge_openpgp_certificates(
|
||||
let SignedPublicKey {
|
||||
primary_key: new_primary_key,
|
||||
details: new_details,
|
||||
public_subkeys: _new_public_subkeys,
|
||||
public_subkeys: new_public_subkeys,
|
||||
} = new_certificate;
|
||||
|
||||
// Public keys may be serialized differently, e.g. using old and new packet type,
|
||||
@@ -411,7 +621,55 @@ pub fn merge_openpgp_certificates(
|
||||
});
|
||||
let users: Vec<SignedUser> = best_user.into_iter().collect();
|
||||
|
||||
let public_subkeys = old_public_subkeys;
|
||||
let (fallback_subkeys, mut rotating_subkeys): (Vec<_>, Vec<_>) =
|
||||
merge_openpgp_subkeys(old_public_subkeys.into_iter().chain(new_public_subkeys))?
|
||||
.into_iter()
|
||||
.filter_map(|subkey| {
|
||||
// Select the newest subkey binding signature.
|
||||
//
|
||||
// There is at most one subkey binding signature at this point
|
||||
// because older subkey binding signatures are removed during merging.
|
||||
let signature = subkey.signatures.iter().find(|signature| {
|
||||
signature
|
||||
.config()
|
||||
.is_some_and(|config| config.typ == SignatureType::SubkeyBinding)
|
||||
})?;
|
||||
|
||||
let created_at_secs = signature.created().unwrap_or(subkey.created_at()).as_secs();
|
||||
let expires_at_secs: Option<u32> = signature
|
||||
.key_expiration_time()
|
||||
.map(|duration| duration.as_secs())
|
||||
.filter(|duration_secs| *duration_secs != 0)
|
||||
.map(|duration_secs| {
|
||||
subkey.created_at().as_secs().saturating_add(duration_secs)
|
||||
});
|
||||
|
||||
Some((subkey, created_at_secs, expires_at_secs))
|
||||
})
|
||||
.partition(|(_subkey, _created_at_secs, expires_at_secs)| expires_at_secs.is_none());
|
||||
let fallback_subkey: Option<SignedPublicSubKey> = fallback_subkeys
|
||||
.into_iter()
|
||||
.max_by_key(|(_subkey, created_at_secs, _)| *created_at_secs)
|
||||
.map(|(subkey, _, _)| subkey);
|
||||
|
||||
rotating_subkeys
|
||||
.sort_by_key(|(_subkey, created_at_secs, _)| std::cmp::Reverse(*created_at_secs));
|
||||
|
||||
// Put the fallback subkey first so it is gossiped first.
|
||||
//
|
||||
// We want to always gossip non-expiring key first
|
||||
// for older versions that always encrypted to the first subkey.
|
||||
//
|
||||
// Keep 10 newest rotating subkeys to avoid storing indefinitely growing number of subkeys locally.
|
||||
let public_subkeys = fallback_subkey
|
||||
.into_iter()
|
||||
.chain(
|
||||
rotating_subkeys
|
||||
.into_iter()
|
||||
.take(10)
|
||||
.map(|(subkey, _, _)| subkey),
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(SignedPublicKey {
|
||||
primary_key: old_primary_key,
|
||||
|
||||
@@ -826,7 +826,9 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref status_update) = mime_parser.webxdc_status_update {
|
||||
if let Some(ref status_update) = mime_parser.webxdc_status_update
|
||||
&& !matches!(mime_parser.pre_message, PreMessageMode::Pre { .. })
|
||||
{
|
||||
let can_info_msg;
|
||||
let instance = if mime_parser
|
||||
.parts
|
||||
@@ -1215,6 +1217,8 @@ async fn decide_chat_assignment(
|
||||
// Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them
|
||||
info!(context, "Email is probably just a draft (TRASH).");
|
||||
true
|
||||
} else if matches!(mime_parser.pre_message, PreMessageMode::Pre { .. }) {
|
||||
false
|
||||
} else if mime_parser.webxdc_status_update.is_some() && mime_parser.parts.len() == 1 {
|
||||
if let Some(part) = mime_parser.parts.first() {
|
||||
if part.typ == Viewtype::Text && part.msg.is_empty() {
|
||||
|
||||
@@ -1663,8 +1663,8 @@ async fn test_save_mime_headers_off() -> anyhow::Result<()> {
|
||||
|
||||
let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert_eq!(msg.get_text(), "hi!");
|
||||
let mime = message::get_mime_headers(&bob, msg.id).await?;
|
||||
assert!(mime.is_empty());
|
||||
let html = msg.id.get_html(&bob).await?;
|
||||
assert!(html.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2030,12 +2030,12 @@ async fn test_no_smtp_job_for_self_chat() -> Result<()> {
|
||||
let chat_id = bob.get_self_chat().await.id;
|
||||
let mut msg = Message::new_text("Happy birthday to me".to_string());
|
||||
chat::send_msg(bob, chat_id, &mut msg).await?;
|
||||
assert!(bob.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
assert!(bob.pop_sent_msg_opt().await.is_none());
|
||||
|
||||
bob.set_config_bool(Config::BccSelf, true).await?;
|
||||
let mut msg = Message::new_text("Happy birthday to me".to_string());
|
||||
chat::send_msg(bob, chat_id, &mut msg).await?;
|
||||
assert!(bob.pop_sent_msg_opt(Duration::ZERO).await.is_some());
|
||||
assert!(bob.pop_sent_msg_opt().await.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4059,6 +4059,8 @@ async fn test_delayed_removal_is_ignored() -> Result<()> {
|
||||
remove_contact_from_chat(bob, bob_chat_id, bob_contact_fiona).await?;
|
||||
let remove_msg = bob.pop_sent_msg().await;
|
||||
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
|
||||
// Bob adds new members Dom and Elena, but first addition message is lost.
|
||||
let dom = &tcm.dom().await;
|
||||
let elena = &tcm.elena().await;
|
||||
@@ -4075,6 +4077,8 @@ async fn test_delayed_removal_is_ignored() -> Result<()> {
|
||||
alice.recv_msg(&add_msg).await;
|
||||
assert_eq!(get_chat_contacts(alice, chat_id).await?.len(), 4);
|
||||
|
||||
SystemTime::shift(Duration::from_secs(1));
|
||||
|
||||
// Alice re-adds Fiona.
|
||||
add_contact_to_chat(alice, chat_id, alice_fiona).await?;
|
||||
assert_eq!(get_chat_contacts(alice, chat_id).await?.len(), 5);
|
||||
|
||||
@@ -454,10 +454,12 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
.update_metadata(ctx)
|
||||
.await
|
||||
.context("update_metadata")?;
|
||||
session
|
||||
.register_token(ctx)
|
||||
.await
|
||||
.context("Failed to register push token")?;
|
||||
if let Err(err) = session.register_token(ctx).await {
|
||||
warn!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Failed to register push token: {err:#}."
|
||||
);
|
||||
}
|
||||
|
||||
let session = fetch_idle(ctx, imap, session).await?;
|
||||
Ok(session)
|
||||
|
||||
@@ -533,6 +533,18 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
warn!(context, "Secure-join denied (bad auth).");
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
if Contact::lookup_id_by_addr_ex(
|
||||
context,
|
||||
&mime_message.from.addr,
|
||||
Origin::Unknown,
|
||||
Some(Blocked::Yes),
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
warn!(context, "Ignoring {step} message: {contact_id} is blocked.");
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
|
||||
let rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let addr = ContactAddress::new(&mime_message.from.addr)?;
|
||||
@@ -640,6 +652,10 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
if time() < timestamp + VERIFICATION_TIMEOUT_SECONDS {
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
}
|
||||
if sender_contact.blocked {
|
||||
warn!(context, "Ignoring {step} message: {contact_id} is blocked.");
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
contact_id.regossip_keys(context).await?;
|
||||
// for setup-contact, make Alice's one-to-one chat with Bob visible
|
||||
// (secure-join-information are shown in the group chat)
|
||||
@@ -811,6 +827,12 @@ pub(crate) async fn observe_securejoin_on_other_device(
|
||||
}
|
||||
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
if contact.blocked && step != SecureJoinStep::MemberAdded {
|
||||
// Contact might be blocked after another device had issued the message. Still, to avoid
|
||||
// membership inconsistency on devices, don't ignore "vg-member-added".
|
||||
warn!(context, "Observing {step}: {contact_id} is blocked.");
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
|
||||
if matches!(
|
||||
step,
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::securejoin::{
|
||||
};
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{create_outgoing_rfc724_mid, smeared_time, time};
|
||||
use crate::tools::{create_outgoing_rfc724_mid, time};
|
||||
use crate::{chatlist_events, mimefactory};
|
||||
|
||||
/// Starts the securejoin protocol with the QR `invite`.
|
||||
@@ -465,7 +465,7 @@ async fn joining_chat_id(
|
||||
name,
|
||||
Blocked::Not,
|
||||
None,
|
||||
smeared_time(context),
|
||||
time(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
@@ -950,7 +950,7 @@ async fn test_parallel_setup_contact(bob_deletes_fiona_contact: bool) -> Result<
|
||||
Contact::delete(bob, bob_fiona_contact_id).await?;
|
||||
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
let sent = bob.pop_sent_msg_opt(Duration::ZERO).await;
|
||||
let sent = bob.pop_sent_msg_opt().await;
|
||||
assert!(sent.is_none());
|
||||
} else {
|
||||
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
|
||||
@@ -1235,7 +1235,7 @@ async fn test_rejoin_group() -> Result<()> {
|
||||
assert_eq!(progress, 1000);
|
||||
|
||||
// Bob does not send any more messages by scanning the QR code.
|
||||
assert!(bob.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
assert!(bob.pop_sent_msg_opt().await.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -40,31 +40,28 @@ impl Smtp {
|
||||
}
|
||||
|
||||
let message_len_bytes = message.len();
|
||||
let recipients_display = recipients
|
||||
.iter()
|
||||
.map(|x| x.as_ref())
|
||||
.collect::<Vec<&str>>()
|
||||
.join(",");
|
||||
|
||||
let envelope =
|
||||
Envelope::new(self.from.clone(), recipients.to_vec()).map_err(Error::Envelope)?;
|
||||
let mail = SendableEmail::new(envelope, message);
|
||||
|
||||
if let Some(ref mut transport) = self.transport {
|
||||
transport.send(mail).await.map_err(Error::SmtpSend)?;
|
||||
|
||||
let info_msg =
|
||||
format!("Message len={message_len_bytes} was SMTP-sent to {recipients_display}");
|
||||
info!(context, "{info_msg}.");
|
||||
context.emit_event(EventType::SmtpMessageSent(info_msg));
|
||||
self.last_success = Some(tools::Time::now());
|
||||
} else {
|
||||
let Some(ref mut transport) = self.transport else {
|
||||
warn!(
|
||||
context,
|
||||
"uh? SMTP has no transport, failed to send to {}", recipients_display
|
||||
"Failed to send a message because SMTP client has no SmtpTransport."
|
||||
);
|
||||
return Err(Error::NoTransport);
|
||||
}
|
||||
};
|
||||
|
||||
transport.send(mail).await.map_err(Error::SmtpSend)?;
|
||||
|
||||
let info_msg = format!(
|
||||
"Message len={message_len_bytes} was SMTP-sent to {} recipients.",
|
||||
recipients.len()
|
||||
);
|
||||
info!(context, "{info_msg}.");
|
||||
context.emit_event(EventType::SmtpMessageSent(info_msg));
|
||||
self.last_success = Some(tools::Time::now());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::ops::{Deref, DerefMut};
|
||||
use std::panic;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_channel::{self as channel, Receiver, Sender};
|
||||
@@ -272,6 +272,7 @@ impl TestContextManager {
|
||||
|
||||
/// Executes SecureJoin initiated by `joiner`
|
||||
/// scanning `qr` generated by one of the `inviters` devices.
|
||||
/// `inviters` devices must have the same primary address.
|
||||
/// All of the `inviters` devices will get the messages and send replies.
|
||||
///
|
||||
/// The [`ChatId`] of the created chat is returned, for a SetupContact QR this is the 1:1
|
||||
@@ -282,9 +283,11 @@ impl TestContextManager {
|
||||
inviters: &[&TestContext],
|
||||
qr: &str,
|
||||
) -> ChatId {
|
||||
assert!(joiner.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
assert!(joiner.pop_sent_msg_opt().await.is_none());
|
||||
let inviter_addr = inviters[0].get_primary_self_addr().await.unwrap();
|
||||
for inviter in inviters {
|
||||
assert!(inviter.pop_sent_msg_opt(Duration::ZERO).await.is_none());
|
||||
assert!(inviter.pop_sent_msg_opt().await.is_none());
|
||||
assert_eq!(inviter.get_primary_self_addr().await.unwrap(), inviter_addr);
|
||||
}
|
||||
|
||||
let chat_id = join_securejoin(&joiner.ctx, qr).await.unwrap();
|
||||
@@ -292,14 +295,22 @@ impl TestContextManager {
|
||||
for _ in 0..2 {
|
||||
let mut something_sent = false;
|
||||
let rev_order = false;
|
||||
if let Some(sent) = joiner.pop_sent_msg_ex(rev_order, Duration::ZERO).await {
|
||||
if let Some(sent) = joiner.pop_sent_msg_ex(rev_order).await {
|
||||
for inviter in inviters {
|
||||
inviter.recv_msg_opt(&sent).await;
|
||||
}
|
||||
something_sent = true;
|
||||
}
|
||||
for inviter in inviters {
|
||||
if let Some(sent) = inviter.pop_sent_msg_ex(rev_order, Duration::ZERO).await {
|
||||
if let Some(sent) = inviter.pop_sent_msg_ex(rev_order).await {
|
||||
if sent.recipients.split(' ').any(|addr| addr == inviter_addr) {
|
||||
for observer in inviters {
|
||||
// `imap::prefetch_should_download()` returns false on the sender side.
|
||||
if observer.get_id() != inviter.get_id() {
|
||||
observer.recv_msg_opt(&sent).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
joiner.recv_msg_opt(&sent).await;
|
||||
something_sent = true;
|
||||
}
|
||||
@@ -638,22 +649,17 @@ impl TestContext {
|
||||
///
|
||||
/// Panics if there is no message or on any error.
|
||||
pub async fn pop_sent_msg(&self) -> SentMessage<'_> {
|
||||
self.pop_sent_msg_opt(Duration::from_secs(3))
|
||||
self.pop_sent_msg_opt()
|
||||
.await
|
||||
.expect("no sent message found in jobs table")
|
||||
}
|
||||
|
||||
pub async fn pop_sent_msg_opt(&self, timeout: Duration) -> Option<SentMessage<'_>> {
|
||||
pub async fn pop_sent_msg_opt(&self) -> Option<SentMessage<'_>> {
|
||||
let rev_order = true;
|
||||
self.pop_sent_msg_ex(rev_order, timeout).await
|
||||
self.pop_sent_msg_ex(rev_order).await
|
||||
}
|
||||
|
||||
pub async fn pop_sent_msg_ex(
|
||||
&self,
|
||||
rev_order: bool,
|
||||
timeout: Duration,
|
||||
) -> Option<SentMessage<'_>> {
|
||||
let start = Instant::now();
|
||||
pub async fn pop_sent_msg_ex(&self, rev_order: bool) -> Option<SentMessage<'_>> {
|
||||
let mut query = "
|
||||
SELECT id, msg_id, mime, recipients
|
||||
FROM smtp
|
||||
@@ -662,28 +668,18 @@ ORDER BY id"
|
||||
if rev_order {
|
||||
query += " DESC";
|
||||
}
|
||||
let (rowid, msg_id, payload, recipients) = loop {
|
||||
let row = self
|
||||
.ctx
|
||||
.sql
|
||||
.query_row_optional(&query, (), |row| {
|
||||
let rowid: i64 = row.get(0)?;
|
||||
let msg_id: MsgId = row.get(1)?;
|
||||
let mime: String = row.get(2)?;
|
||||
let recipients: String = row.get(3)?;
|
||||
Ok((rowid, msg_id, mime, recipients))
|
||||
})
|
||||
.await
|
||||
.expect("query_row_optional failed");
|
||||
if let Some(row) = row {
|
||||
break row;
|
||||
}
|
||||
if start.elapsed() < timeout {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (rowid, msg_id, payload, recipients) = self
|
||||
.ctx
|
||||
.sql
|
||||
.query_row_optional(&query, (), |row| {
|
||||
let rowid: i64 = row.get(0)?;
|
||||
let msg_id: MsgId = row.get(1)?;
|
||||
let mime: String = row.get(2)?;
|
||||
let recipients: String = row.get(3)?;
|
||||
Ok((rowid, msg_id, mime, recipients))
|
||||
})
|
||||
.await
|
||||
.expect("query_row_optional failed")?;
|
||||
self.ctx
|
||||
.sql
|
||||
.execute("DELETE FROM smtp WHERE id=?;", (rowid,))
|
||||
|
||||
@@ -34,7 +34,7 @@ async fn test_additional_text_on_different_viewtypes() -> Result<()> {
|
||||
let (pre_message, _, _) = send_large_image_message(alice, a_group_id).await?;
|
||||
let msg = bob.recv_msg(&pre_message).await;
|
||||
assert_eq!(msg.text, "test".to_owned());
|
||||
assert_eq!(msg.get_text(), "test [Image – 146.12 KiB]".to_owned());
|
||||
assert_eq!(msg.get_text(), "test [Image – 228.45 KiB]".to_owned());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Tests about forwarding and saving Pre-Messages
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -108,12 +107,7 @@ async fn test_receive_both() -> Result<()> {
|
||||
forward_msgs(alice, &[alice_msg_id], alice_chat_id).await?;
|
||||
let rev_order = false;
|
||||
let msg = bob
|
||||
.recv_msg(
|
||||
&alice
|
||||
.pop_sent_msg_ex(rev_order, Duration::ZERO)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.recv_msg(&alice.pop_sent_msg_ex(rev_order).await.unwrap())
|
||||
.await;
|
||||
assert_eq!(msg.download_state(), DownloadState::Available);
|
||||
assert_eq!(msg.is_forwarded(), true);
|
||||
|
||||
@@ -402,9 +402,9 @@ async fn test_receive_pre_message_image() -> Result<()> {
|
||||
// test that metadata is correctly returned by methods
|
||||
assert_eq!(msg.get_post_message_viewtype(), Some(Viewtype::Image));
|
||||
// recoded image dimensions
|
||||
assert_eq!(msg.get_filebytes(bob).await?, Some(149632));
|
||||
assert_eq!(msg.get_height(), 1280);
|
||||
assert_eq!(msg.get_width(), 720);
|
||||
assert_eq!(msg.get_filebytes(bob).await?, Some(233935));
|
||||
assert_eq!(msg.get_height(), 1704);
|
||||
assert_eq!(msg.get_width(), 959);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -534,6 +534,17 @@ async fn test_webxdc_updates_in_post_message_after_pre_message() -> Result<()> {
|
||||
|
||||
let alice_chat_id = alice.create_chat_id(bob).await;
|
||||
|
||||
// regression test where updates get assigned to an unrelated prior webxdc message
|
||||
let mut unrelated_xdc = Message::new(Viewtype::Webxdc);
|
||||
unrelated_xdc.set_file_from_bytes(
|
||||
alice,
|
||||
"first.xdc",
|
||||
include_bytes!("../../../test-data/webxdc/minimal.xdc"),
|
||||
None,
|
||||
)?;
|
||||
send_msg(alice, alice_chat_id, &mut unrelated_xdc).await?;
|
||||
let bob_unrelated_webxdc = bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
|
||||
let big_webxdc_app = big_webxdc_app().await?;
|
||||
|
||||
let mut alice_instance = Message::new(Viewtype::Webxdc);
|
||||
@@ -552,6 +563,14 @@ async fn test_webxdc_updates_in_post_message_after_pre_message() -> Result<()> {
|
||||
|
||||
let bob_instance = bob.recv_msg(&pre_message).await;
|
||||
assert_eq!(bob_instance.download_state, DownloadState::Available);
|
||||
|
||||
// don't accidentally assign updates from a pre-message to parent message
|
||||
assert_eq!(
|
||||
bob.get_webxdc_status_updates(bob_unrelated_webxdc.id, StatusUpdateSerial::new(0))
|
||||
.await?,
|
||||
"[]"
|
||||
);
|
||||
|
||||
bob.recv_msg_trash(&post_message).await;
|
||||
let bob_instance = Message::load_from_db(bob, bob_instance.id).await?;
|
||||
assert_eq!(bob_instance.download_state, DownloadState::Done);
|
||||
@@ -565,6 +584,51 @@ async fn test_webxdc_updates_in_post_message_after_pre_message() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests sending large webxdc without text.
|
||||
///
|
||||
/// This is a regression test, previously pre-message
|
||||
/// was trashed when it had no text.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_large_webxdc_without_text() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
tcm.section("Bob sends large webxdc without attached text message.");
|
||||
let bob_chat_id = bob.create_chat_id(alice).await;
|
||||
let big_webxdc_app = big_webxdc_app().await?;
|
||||
let mut bob_instance = Message::new(Viewtype::Webxdc);
|
||||
bob_instance.set_file_from_bytes(bob, "test.xdc", &big_webxdc_app, None)?;
|
||||
bob_chat_id.set_draft(bob, Some(&mut bob_instance)).await?;
|
||||
bob.send_webxdc_status_update(bob_instance.id, r#"{"payload":42, "info":"i"}"#)
|
||||
.await?;
|
||||
|
||||
send_msg(bob, bob_chat_id, &mut bob_instance).await?;
|
||||
let post_message = bob.pop_sent_msg().await;
|
||||
let pre_message = bob.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Alice receives a pre-message");
|
||||
let alice_instance = alice.recv_msg(&pre_message).await;
|
||||
assert_eq!(alice_instance.download_state, DownloadState::Available);
|
||||
|
||||
tcm.section("Alice receives a post-message");
|
||||
alice.recv_msg_trash(&post_message).await;
|
||||
let alice_instance = Message::load_from_db(alice, alice_instance.id).await?;
|
||||
assert_eq!(alice_instance.download_state, DownloadState::Done);
|
||||
|
||||
let alice_file_path = alice_instance.get_file(alice).expect("No file");
|
||||
tokio::fs::try_exists(alice_file_path).await?;
|
||||
|
||||
assert_eq!(
|
||||
alice
|
||||
.get_webxdc_status_updates(alice_instance.id, StatusUpdateSerial::new(0))
|
||||
.await?,
|
||||
r#"[{"payload":42,"info":"i","serial":1,"max_serial":1}]"#
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_webxdc_updates_in_post_message_after_deleted_pre_message() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
//! # Time smearing.
|
||||
//!
|
||||
//! As e-mails typically only use a second-based-resolution for timestamps,
|
||||
//! the order of two mails sent within one second is unclear.
|
||||
//! This is bad e.g. when forwarding some messages from a chat -
|
||||
//! these messages will appear at the recipient easily out of order.
|
||||
//!
|
||||
//! We work around this issue by not sending out two mails with the same timestamp.
|
||||
//! For this purpose, in short, we track the last timestamp used in `last_smeared_timestamp`
|
||||
//! when another timestamp is needed in the same second, we use `last_smeared_timestamp+1`
|
||||
//! after some moments without messages sent out,
|
||||
//! `last_smeared_timestamp` is again in sync with the normal time.
|
||||
//!
|
||||
//! However, we do not do all this for the far future,
|
||||
//! but at max `MAX_SECONDS_TO_LEND_FROM_FUTURE`
|
||||
|
||||
use std::cmp::{max, min};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
pub(crate) const MAX_SECONDS_TO_LEND_FROM_FUTURE: i64 = 30;
|
||||
|
||||
/// Smeared timestamp generator.
|
||||
#[derive(Debug)]
|
||||
pub struct SmearedTimestamp {
|
||||
/// Next timestamp available for allocation.
|
||||
smeared_timestamp: AtomicI64,
|
||||
}
|
||||
|
||||
impl SmearedTimestamp {
|
||||
/// Creates a new smeared timestamp generator.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
smeared_timestamp: AtomicI64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocates `count` unique timestamps.
|
||||
///
|
||||
/// Returns the first allocated timestamp.
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
pub fn create_n(&self, now: i64, count: i64) -> i64 {
|
||||
let mut prev = self.smeared_timestamp.load(Ordering::Relaxed);
|
||||
loop {
|
||||
// Advance the timestamp if it is in the past,
|
||||
// but keep `count - 1` timestamps from the past if possible.
|
||||
let t = max(prev, now - count + 1);
|
||||
|
||||
// Rewind the time back if there is no room
|
||||
// to allocate `count` timestamps without going too far into the future.
|
||||
// Not going too far into the future
|
||||
// is more important than generating unique timestamps.
|
||||
let first = min(t, now + MAX_SECONDS_TO_LEND_FROM_FUTURE - count + 1);
|
||||
|
||||
// Allocate `count` timestamps by advancing the current timestamp.
|
||||
let next = first + count;
|
||||
|
||||
if let Err(x) = self.smeared_timestamp.compare_exchange_weak(
|
||||
prev,
|
||||
next,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
prev = x;
|
||||
} else {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a single timestamp.
|
||||
pub fn create(&self, now: i64) -> i64 {
|
||||
self.create_n(now, 1)
|
||||
}
|
||||
|
||||
/// Returns the current smeared timestamp.
|
||||
pub fn current(&self) -> i64 {
|
||||
self.smeared_timestamp.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::TestContext;
|
||||
use crate::tools::{
|
||||
SystemTime, create_smeared_timestamp, create_smeared_timestamps, smeared_time, time,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_smeared_timestamp() {
|
||||
let smeared_timestamp = SmearedTimestamp::new();
|
||||
let now = time();
|
||||
|
||||
assert_eq!(smeared_timestamp.current(), 0);
|
||||
|
||||
for i in 0..MAX_SECONDS_TO_LEND_FROM_FUTURE {
|
||||
assert_eq!(smeared_timestamp.create(now), now + i);
|
||||
}
|
||||
assert_eq!(
|
||||
smeared_timestamp.create(now),
|
||||
now + MAX_SECONDS_TO_LEND_FROM_FUTURE
|
||||
);
|
||||
assert_eq!(
|
||||
smeared_timestamp.create(now),
|
||||
now + MAX_SECONDS_TO_LEND_FROM_FUTURE
|
||||
);
|
||||
|
||||
// System time rewinds back by 1000 seconds.
|
||||
let now = now - 1000;
|
||||
assert_eq!(
|
||||
smeared_timestamp.create(now),
|
||||
now + MAX_SECONDS_TO_LEND_FROM_FUTURE
|
||||
);
|
||||
assert_eq!(
|
||||
smeared_timestamp.create(now),
|
||||
now + MAX_SECONDS_TO_LEND_FROM_FUTURE
|
||||
);
|
||||
assert_eq!(
|
||||
smeared_timestamp.create(now + 1),
|
||||
now + MAX_SECONDS_TO_LEND_FROM_FUTURE + 1
|
||||
);
|
||||
assert_eq!(smeared_timestamp.create(now + 100), now + 100);
|
||||
assert_eq!(smeared_timestamp.create(now + 100), now + 101);
|
||||
assert_eq!(smeared_timestamp.create(now + 100), now + 102);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_n_smeared_timestamps() {
|
||||
let smeared_timestamp = SmearedTimestamp::new();
|
||||
let now = time();
|
||||
|
||||
// Create a single timestamp to initialize the generator.
|
||||
assert_eq!(smeared_timestamp.create(now), now);
|
||||
|
||||
// Wait a minute.
|
||||
let now = now + 60;
|
||||
|
||||
// Simulate forwarding 7 messages.
|
||||
let forwarded_messages = 7;
|
||||
|
||||
// We have not sent anything for a minute,
|
||||
// so we can take the current timestamp and take 6 timestamps from the past.
|
||||
assert_eq!(smeared_timestamp.create_n(now, forwarded_messages), now - 6);
|
||||
|
||||
assert_eq!(smeared_timestamp.current(), now + 1);
|
||||
|
||||
// Wait 4 seconds.
|
||||
// Now we have 3 free timestamps in the past.
|
||||
let now = now + 4;
|
||||
|
||||
assert_eq!(smeared_timestamp.current(), now - 3);
|
||||
|
||||
// Forward another 7 messages.
|
||||
// We can only lend 3 timestamps from the past.
|
||||
assert_eq!(smeared_timestamp.create_n(now, forwarded_messages), now - 3);
|
||||
|
||||
// We had to borrow 3 timestamps from the future
|
||||
// because there were not enough timestamps in the past.
|
||||
assert_eq!(smeared_timestamp.current(), now + 4);
|
||||
|
||||
// Forward another 32 messages.
|
||||
// We cannot use more than 30 timestamps from the future,
|
||||
// so we use 30 timestamps from the future,
|
||||
// the current timestamp and one timestamp from the past.
|
||||
assert_eq!(smeared_timestamp.create_n(now, 32), now - 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_create_smeared_timestamp() {
|
||||
let t = TestContext::new().await;
|
||||
assert_ne!(create_smeared_timestamp(&t), create_smeared_timestamp(&t));
|
||||
assert!(
|
||||
create_smeared_timestamp(&t)
|
||||
>= SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_create_smeared_timestamps() {
|
||||
let t = TestContext::new().await;
|
||||
let count = MAX_SECONDS_TO_LEND_FROM_FUTURE - 1;
|
||||
let start = create_smeared_timestamps(&t, count as usize);
|
||||
let next = smeared_time(&t);
|
||||
assert!((start + count - 1) < next);
|
||||
|
||||
let count = MAX_SECONDS_TO_LEND_FROM_FUTURE + 30;
|
||||
let start = create_smeared_timestamps(&t, count as usize);
|
||||
let next = smeared_time(&t);
|
||||
assert!((start + count - 1) < next);
|
||||
}
|
||||
}
|
||||
23
src/tools.rs
23
src/tools.rs
@@ -180,29 +180,6 @@ pub(crate) fn gm2local_offset() -> i64 {
|
||||
i64::from(lt.offset().local_minus_utc())
|
||||
}
|
||||
|
||||
/// Returns the current smeared timestamp,
|
||||
///
|
||||
/// The returned timestamp MAY NOT be unique and MUST NOT go to "Date" header.
|
||||
pub(crate) fn smeared_time(context: &Context) -> i64 {
|
||||
let now = time();
|
||||
let ts = context.smeared_timestamp.current();
|
||||
std::cmp::max(ts, now)
|
||||
}
|
||||
|
||||
/// Returns a timestamp that is guaranteed to be unique.
|
||||
pub(crate) fn create_smeared_timestamp(context: &Context) -> i64 {
|
||||
let now = time();
|
||||
context.smeared_timestamp.create(now)
|
||||
}
|
||||
|
||||
// creates `count` timestamps that are guaranteed to be unique.
|
||||
// the first created timestamps is returned directly,
|
||||
// get the other timestamps just by adding 1..count-1
|
||||
pub(crate) fn create_smeared_timestamps(context: &Context, count: usize) -> i64 {
|
||||
let now = time();
|
||||
context.smeared_timestamp.create_n(now, count as i64)
|
||||
}
|
||||
|
||||
/// Returns the last release timestamp as a unix timestamp compatible for comparison with time() and
|
||||
/// database times.
|
||||
pub fn get_release_timestamp() -> i64 {
|
||||
|
||||
@@ -46,7 +46,7 @@ use crate::mimefactory::RECOMMENDED_FILE_SIZE;
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::param::Param;
|
||||
use crate::param::Params;
|
||||
use crate::tools::{create_id, create_smeared_timestamp, get_abs_path};
|
||||
use crate::tools::{create_id, get_abs_path, time};
|
||||
|
||||
/// The current API version.
|
||||
/// If `min_api` in manifest.toml is set to a larger value,
|
||||
@@ -558,7 +558,7 @@ impl Context {
|
||||
.create_status_update_record(
|
||||
&instance,
|
||||
status_update,
|
||||
create_smeared_timestamp(self),
|
||||
time(),
|
||||
send_now,
|
||||
ContactId::SELF,
|
||||
)
|
||||
|
||||
@@ -145,7 +145,6 @@ mod tests {
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::test_utils::TestContext;
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_default_integrations_are_single_device() -> Result<()> {
|
||||
@@ -158,7 +157,7 @@ mod tests {
|
||||
t.set_webxdc_integration(file.to_str().unwrap()).await?;
|
||||
|
||||
// default integrations are shipped with the apps and should not be sent over the wire
|
||||
let sent = t.pop_sent_msg_opt(Duration::from_secs(1)).await;
|
||||
let sent = t.pop_sent_msg_opt().await;
|
||||
assert!(sent.is_none());
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
OutBroadcast#Chat#1001: Channel [0 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1008🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,6 @@ OutBroadcast#Chat#1001: Channel [0 member(s)]
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#1002: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
|
||||
Msg#1006🔒: Me (Contact#Contact#Self): Member bob@example.net added. [INFO] √
|
||||
Msg#1008🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
Msg#1009🔒: Me (Contact#Contact#Self): hi √
|
||||
Msg#1010🔒: Me (Contact#Contact#Self): You removed member bob@example.net. [INFO] √
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user