mirror of
https://github.com/chatmail/core.git
synced 2026-04-07 16:12:10 +03:00
Compare commits
27 Commits
v2.46.0
...
link2xt/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac9c6bb09 | ||
|
|
84459b6495 | ||
|
|
c99b8a4482 | ||
|
|
76e2c36d85 | ||
|
|
1b8bf4ed23 | ||
|
|
c553357c60 | ||
|
|
ebe8550c52 | ||
|
|
2637c3bea4 | ||
|
|
d1f1633c60 | ||
|
|
98b55ec15f | ||
|
|
6a3ef20a99 | ||
|
|
59be03a7eb | ||
|
|
8528184fa3 | ||
|
|
5ab1fdca2e | ||
|
|
f616d1bd6c | ||
|
|
e885e052c3 | ||
|
|
6b1e62faba | ||
|
|
7b9e7ae611 | ||
|
|
aedc60f1cc | ||
|
|
017099215c | ||
|
|
e86b170969 | ||
|
|
452ac8a1bc | ||
|
|
5d06ca3c8e | ||
|
|
bdc9e7ce56 | ||
|
|
e30d833c94 | ||
|
|
16668b45e9 | ||
|
|
b148be2618 |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,16 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## [2.47.0] - 2026-03-24
|
||||
|
||||
### Fixes
|
||||
|
||||
- Don't fall into infinite loop if the folder is missing ([#8021](https://github.com/chatmail/core/pull/8021)).
|
||||
- Delete `available_post_msgs` row if the message is already downloaded.
|
||||
- Delete `available_post_msgs` row if there is no corresponding IMAP entry.
|
||||
- Make newlines work in chat descriptions ([#8012](https://github.com/chatmail/core/pull/8012)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- use SEIPDv2 if all recipients support it.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add shadowsocks spec to standards.md.
|
||||
- Document Header Confidentiality Policy.
|
||||
- `deltachat_rpc_client`: make sphinx documentation display method parameters.
|
||||
- Remove `draft/aeap-mvp.md` which is superseded by key-contacts and multi-relay.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove code to send messages without intended recipient fingerprint.
|
||||
|
||||
### Tests
|
||||
|
||||
- Make `add_or_lookup_contact_id_no_key` public.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: bump sdp from 0.10.0 to 0.17.1.
|
||||
- Add RUSTSEC-2026-0049 exception to deny.toml.
|
||||
|
||||
## [2.46.0] - 2026-03-19
|
||||
|
||||
### API-Changes
|
||||
|
||||
- [**breaking**] remove functions for sending and receiving Autocrypt Setup Message.
|
||||
- Add `list_transports_ex()` and `set_transport_unpublished()` functions.
|
||||
- Add API `dc_markfresh_chat` to mark messages as "fresh".
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- add `IncomingCallAccepted.from_this_device`.
|
||||
- mark messages as "fresh".
|
||||
- decode `dcaccount://` URLs and error out on empty URLs early.
|
||||
- enable anonymous OpenPGP key IDs.
|
||||
- tls: do not verify TLS certificates for hostnames starting with `_`.
|
||||
@@ -7950,3 +7983,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[2.44.0]: https://github.com/chatmail/core/compare/v2.43.0..v2.44.0
|
||||
[2.45.0]: https://github.com/chatmail/core/compare/v2.44.0..v2.45.0
|
||||
[2.46.0]: https://github.com/chatmail/core/compare/v2.45.0..v2.46.0
|
||||
[2.47.0]: https://github.com/chatmail/core/compare/v2.46.0..v2.47.0
|
||||
|
||||
39
Cargo.lock
generated
39
Cargo.lock
generated
@@ -1307,7 +1307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -1416,7 +1416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
@@ -1437,7 +1437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-repl"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1453,7 +1453,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -1482,7 +1482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deltachat",
|
||||
@@ -2986,7 +2986,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.102.8",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"spki",
|
||||
@@ -3175,7 +3175,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.102.8",
|
||||
"serde",
|
||||
"sha1",
|
||||
"strum 0.26.2",
|
||||
@@ -5156,15 +5156,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.23"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.103.10",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -5199,6 +5199,17 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.14"
|
||||
@@ -5307,9 +5318,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdp"
|
||||
version = "0.10.0"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6"
|
||||
checksum = "22c3b0257608d7de4de4c4ea650ccc2e6e3e45e3cd80039fcdee768bcb449253"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"substring",
|
||||
@@ -6171,9 +6182,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.2"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.88"
|
||||
@@ -87,7 +87,7 @@ rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
rusqlite = { workspace = true, features = ["sqlcipher"] }
|
||||
sanitize-filename = { workspace = true }
|
||||
sdp = "0.10.0"
|
||||
sdp = "0.17.1"
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = "0.7.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -5959,7 +5959,7 @@ char* dc_jsonrpc_blocking_call(dc_jsonrpc_instance_t* jsonrpc_instance, const ch
|
||||
* @memberof dc_event_channel_t
|
||||
* @return An event channel wrapper object (dc_event_channel_t).
|
||||
*/
|
||||
dc_event_channel_t* dc_event_channel_new();
|
||||
dc_event_channel_t* dc_event_channel_new(void);
|
||||
|
||||
/**
|
||||
* Release/free the events channel structure.
|
||||
|
||||
@@ -306,20 +306,17 @@ pub unsafe extern "C" fn dc_set_stock_translation(
|
||||
let msg = to_string_lossy(stock_msg);
|
||||
let ctx = &*context;
|
||||
|
||||
block_on(async move {
|
||||
match StockMessage::from_u32(stock_id)
|
||||
.with_context(|| format!("Invalid stock message ID {stock_id}"))
|
||||
match StockMessage::from_u32(stock_id)
|
||||
.with_context(|| format!("Invalid stock message ID {stock_id}"))
|
||||
.log_err(ctx)
|
||||
{
|
||||
Ok(id) => ctx
|
||||
.set_stock_translation(id, msg)
|
||||
.context("set_stock_translation failed")
|
||||
.log_err(ctx)
|
||||
{
|
||||
Ok(id) => ctx
|
||||
.set_stock_translation(id, msg)
|
||||
.await
|
||||
.context("set_stock_translation failed")
|
||||
.log_err(ctx)
|
||||
.is_ok() as libc::c_int,
|
||||
Err(_) => 0,
|
||||
}
|
||||
})
|
||||
.is_ok() as libc::c_int,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -11,8 +11,8 @@ use deltachat::blob::BlobObject;
|
||||
use deltachat::calls::ice_servers;
|
||||
use deltachat::chat::{
|
||||
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
|
||||
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
|
||||
ChatId, ChatItem, MessageListOptions,
|
||||
get_chat_msgs_ex, markfresh_chat, marknoticed_all_chats, marknoticed_chat,
|
||||
remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
|
||||
};
|
||||
use deltachat::chatlist::Chatlist;
|
||||
use deltachat::config::{get_all_ui_config_keys, Config};
|
||||
@@ -473,9 +473,7 @@ impl CommandApi {
|
||||
let accounts = self.accounts.read().await;
|
||||
for (stock_id, stock_message) in strings {
|
||||
if let Some(stock_id) = StockMessage::from_u32(stock_id) {
|
||||
accounts
|
||||
.set_stock_translation(stock_id, stock_message)
|
||||
.await?;
|
||||
accounts.set_stock_translation(stock_id, stock_message)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1251,6 +1249,15 @@ impl CommandApi {
|
||||
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
|
||||
}
|
||||
|
||||
/// Marks the last incoming message in the chat as _fresh_.
|
||||
///
|
||||
/// UI can use this to offer a "mark unread" option,
|
||||
/// so that already noticed chats get a badge counter again.
|
||||
async fn markfresh_chat(&self, account_id: u32, chat_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
markfresh_chat(&ctx, ChatId::new(chat_id)).await
|
||||
}
|
||||
|
||||
/// Returns the message that is immediately followed by the last seen
|
||||
/// message.
|
||||
/// From the point of view of the user this is effectively
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.46.0"
|
||||
"version": "2.48.0-dev"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.46.0"
|
||||
version = "2.48.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.46.0"
|
||||
version = "2.48.0-dev"
|
||||
license = "MPL-2.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
|
||||
@@ -187,13 +187,9 @@ class futuremethod: # noqa: N801
|
||||
"""Decorator for async methods."""
|
||||
|
||||
def __init__(self, func):
|
||||
functools.update_wrapper(self, func)
|
||||
self._func = func
|
||||
|
||||
def __get__(self, instance, owner=None):
|
||||
if instance is None:
|
||||
return self
|
||||
|
||||
def future(*args):
|
||||
generator = self._func(instance, *args)
|
||||
res = next(generator)
|
||||
@@ -206,6 +202,7 @@ class futuremethod: # noqa: N801
|
||||
|
||||
return f
|
||||
|
||||
@functools.wraps(self._func)
|
||||
def wrapper(*args):
|
||||
f = future(*args)
|
||||
return f()
|
||||
|
||||
@@ -219,6 +219,10 @@ class Chat:
|
||||
"""Mark all messages in this chat as noticed."""
|
||||
self._rpc.marknoticed_chat(self.account.id, self.id)
|
||||
|
||||
def mark_fresh(self) -> None:
|
||||
"""Mark the last incoming message in the chat as fresh."""
|
||||
self._rpc.markfresh_chat(self.account.id, self.id)
|
||||
|
||||
def add_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
|
||||
"""Add contacts to this group."""
|
||||
from .account import Account
|
||||
|
||||
@@ -273,6 +273,9 @@ def test_chat(acfactory) -> None:
|
||||
assert group.get_messages()
|
||||
group.get_fresh_message_count()
|
||||
group.mark_noticed()
|
||||
assert group.get_fresh_message_count() == 0
|
||||
group.mark_fresh()
|
||||
assert group.get_fresh_message_count() > 0
|
||||
assert group.get_contacts()
|
||||
assert group.get_past_contacts() == []
|
||||
group.remove_contact(alice_contact_bob)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.46.0"
|
||||
version = "2.48.0-dev"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.46.0"
|
||||
"version": "2.48.0-dev"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ ignore = [
|
||||
# It is a transitive dependency of iroh 0.35.0,
|
||||
# this should be fixed by upgrading to iroh 1.0 once it is released.
|
||||
"RUSTSEC-2025-0134",
|
||||
|
||||
# rustls-webpki v0.102.8
|
||||
# We cannot upgrade to >=0.103.10 because
|
||||
# it is a transitive dependency of iroh 0.35.0
|
||||
# which depends on ^0.102.
|
||||
# <https://rustsec.org/advisories/RUSTSEC-2026-0049>
|
||||
"RUSTSEC-2026-0049",
|
||||
]
|
||||
|
||||
[bans]
|
||||
@@ -42,6 +49,7 @@ skip = [
|
||||
{ name = "rand_core", version = "0.6.4" },
|
||||
{ name = "rand", version = "0.8.5" },
|
||||
{ name = "rustix", version = "0.38.44" },
|
||||
{ name = "rustls-webpki", version = "0.102.8" },
|
||||
{ name = "serdect", version = "0.2.0" },
|
||||
{ name = "socket2", version = "0.5.9" },
|
||||
{ name = "spin", version = "0.9.8" },
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
AEAP MVP
|
||||
========
|
||||
|
||||
Changes to the UIs
|
||||
------------------
|
||||
|
||||
- The secondary self addresses (see below) are shown in the UI, but not editable.
|
||||
|
||||
- When the user changed the email address in the configure screen, show a dialog to the user, either directly explaining things or with a link to the FAQ (see "Other" below)
|
||||
|
||||
Changes in the core
|
||||
-------------------
|
||||
|
||||
- [x] We have one primary self address and any number of secondary self addresses. `is_self_addr()` checks all of them.
|
||||
|
||||
- [x] If the user does a reconfigure and changes the email address, the previous address is added as a secondary self address.
|
||||
|
||||
- don't forget to deduplicate secondary self addresses in case the user switches back and forth between addresses).
|
||||
|
||||
- The key stays the same.
|
||||
|
||||
- [x] No changes for 1:1 chats, there simply is a new one. (This works since, contrary to group messages, messages sent to a 1:1 chat are not assigned to the group chat but always to the 1:1 chat with the sender. So it's not a problem that the new messages might be put into the old chat if they are a reply to a message there.)
|
||||
|
||||
- [ ] When sending a message: If any of the secondary self addrs is in the chat's member list, remove it locally (because we just transitioned away from it). We add a log message for this (alternatively, a system message in the chat would be more visible).
|
||||
|
||||
- [x] ([#3385](https://github.com/deltachat/deltachat-core-rust/pull/3385)) When receiving a message: If the key exists, but belongs to another address (we may want to benchmark this)
|
||||
AND there is a `Chat-Version` header\
|
||||
AND the message is signed correctly
|
||||
AND the From address is (also) in the encrypted (and therefore signed) headers <sup>[[1]](#myfootnote1)</sup>\
|
||||
AND the message timestamp is newer than the contact's `lastseen` (to prevent changing the address back when messages arrive out of order) (this condition is not that important since we will have eventual consistency even without it):
|
||||
|
||||
Replace the contact in _all_ groups, possibly deduplicate the members list, and add a system message to all of these chats.
|
||||
|
||||
- Note that we can't simply compare the keys byte-by-byte, since the UID may have changed, or the sender may have rotated the key and signed the new key with the old one.
|
||||
|
||||
<a name="myfootnote1">[1]</a>: Without this check, an attacker could replay a message from Alice to Bob. Then Bob's device would do an AEAP transition from Alice's to the attacker's address, allowing for easier phishing.
|
||||
|
||||
<details>
|
||||
<summary>More details about this</summary>
|
||||
Suppose Alice sends a message to Evil (or to a group with both Evil and Bob). Evil then forwards the message to Bob, changing the From and To headers (and if necessary Message-Id) and replacing `addr=alice@example.org;` in the autocrypt header with `addr=evil@example.org;`.
|
||||
|
||||
Then Bob's device sees that there is a message which is signed by Alice's key and comes from Evil's address and would do the AEAP transition, i.e. replace Alice with Evil in all groups and show a message "Alice changed their address from alice@example.org to evil@example.org". Disadvantages for Evil are that Bob's message will be shown on Alice's device, possibly creating confusion/suspicion, and that the usual "Setup changed for..." message will be shown the next time Evil sends a message (because Evil doesn't know Alice's private key).
|
||||
|
||||
Possible mitigations:
|
||||
- if we make the AEAP device message sth. like "Automatically removed alice@example.org and added evil@example.org", then this will create more suspicion, making the phishing harder (we didn't talk about what what the wording should be at all yet).
|
||||
- Add something similar to replay protection to our Autocrypt implementation. This could be done e.g. by adding a second `From` header to the protected headers. If it's present, the receiver then requires it to be the same as the outer `From`, and if it's not present, we don't do AEAP --> **That's what we implemented**
|
||||
|
||||
Note that usually a mail is signed by a key that has a UID matching the from address.
|
||||
|
||||
That's not mandatory for Autocrypt (and in fact, we just keep the old UID when changing the self address, so with AEAP the UID will actually be different than the from address sometimes)
|
||||
|
||||
https://autocrypt.org/level1.html#openpgp-based-key-data says:
|
||||
> The content of the user id packet is only decorative
|
||||
|
||||
</details>
|
||||
|
||||
### Notes:
|
||||
|
||||
- We treat protected and non-protected chats the same
|
||||
- We leave the aeap transition statement away since it seems not to be needed, makes things harder on the sending side, wastes some network traffic, and is worse for privacy (since more people know what old addresses you had).
|
||||
- As soon as we encrypt read receipts, sending a read receipt will be enough to tell a lot of people that you transitioned
|
||||
- AEAP will make the problem of inconsistent group state worse, both because it doesn't work if the message is unencrypted (even if the design allowed it, it would be problematic security-wise) and because some chat partners may have gotten the transition and some not. We should do something against this at some point in the future, like asking the user whether they want to add/remove the members to restore consistent group state.
|
||||
|
||||
#### Downsides of this design:
|
||||
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
|
||||
|
||||
#### Upsides:
|
||||
- With this approach, it's easy to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
|
||||
- Faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.
|
||||
|
||||
### Alternatives and old discussions/plans:
|
||||
|
||||
- Change the contact instead of rewriting the group member lists. This seems to call for more trouble since we will end up with multiple contacts having the same email address.
|
||||
|
||||
- If needed, we could add a header a) indicating that the sender did an address transition or b) listing all the secondary (old) addresses. For now, there is no big enough benefit to warrant introducing another header and its processing on the receiver side (including all the necessary checks and handling of error cases). Instead, we only check for the `Chat-Version` header to prevent accidental transitions when an MUA user sends a message from another email address with the same key.
|
||||
|
||||
- The condition for a transition temporarily was:
|
||||
|
||||
> When receiving a message: If we are going to assign a message to a chat, but the sender is not a member of this chat\
|
||||
> AND the signing key is the same as the direct (non-gossiped) key of one of the chat members\
|
||||
> AND ...
|
||||
|
||||
However, this would mean that in 1:1 messages can't trigger a transition, since we don't assign private messages to the parent chat, but always to the 1:1 chat with the sender.
|
||||
|
||||
<details>
|
||||
<summary>Some previous state of the discussion, which temporarily lived in an issue description</summary>
|
||||
Summarizing the discussions from https://github.com/deltachat/deltachat-core-rust/pull/2896, mostly quoting @hpk42:
|
||||
|
||||
1. (DONE) At the time of configure we push the current primary to become a secondary.
|
||||
|
||||
2. When a message is sent out to a chat, and the message is encrypted, and we have secondary addresses, then we
|
||||
a) add a protected "AEAP-Replacement" header that contains all secondary addresses
|
||||
b) if any of the secondary addresses is in the chat's member list, we remove it and leave a system message that we did so
|
||||
3. When an encrypted message with a replacement header is received, replace the e-mail address of all secondary contacts (if they exist) with the new primary and drop a sysmessage in all chats the secondary is member off. This might (in edge cases) result in chats that have two or more contacts with the same e-mail address. We might ignore this for a first release and just log a warning. Let's maybe not get hung up on this case before everything else works.
|
||||
|
||||
Notes:
|
||||
- for now we will send out aeap replacement headers forever, there is no termination condition other than lack of secondary addresses. I think that's fine for now. Later on we might introduce options to remove secondary addresses but i wouldn't do this for a first release/PR.
|
||||
- the design is resilient against changing e-mail providers from A to B to C and then back to A, with partially updated chats and diverging views from recipients/contacts on this transition. In the end, you will have a primary and some secondaries, and when you start sending out messages everybody will eventually synchronize when they receive the current state of primaries/secondaries.
|
||||
- of course on incoming message for need to check for each stated secondary address in the replacement header that it uses the same signature as the signature we verified as valid with the incoming message **--> Also we have to somehow make sure that the signing key was not just gossiped from some random other person in some group.**
|
||||
- there are no extra flags/columns in the database needed (i hope)
|
||||
|
||||
#### Downsides of the chosen approach:
|
||||
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
|
||||
- There will be multiple contacts with the same address in the database. We will have to do something against this at some point.
|
||||
|
||||
The most obvious alternative would be to create a new contact with the new address and replace the old contact in the groups.
|
||||
|
||||
#### Upsides:
|
||||
- With this approach, it's easier to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
|
||||
- (Also, less important: Slightly faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.)
|
||||
- It's easier to implement (if too many problems turn up, we can still switch to another approach and didn't waste that much development time.)
|
||||
|
||||
[full messages](https://github.com/deltachat/deltachat-core-rust/pull/2896#discussion_r852002161)
|
||||
|
||||
_end of the previous state of the discussion_
|
||||
|
||||
</details>
|
||||
|
||||
Other
|
||||
-----
|
||||
|
||||
- The user is responsible that messages to the old address arrive at the new address, for example by configuring the old provider to forward all emails to the new one.
|
||||
|
||||
Notes during implementing
|
||||
========================
|
||||
|
||||
- As far as I understand the code, unencrypted messages are unsigned. So, the transition only works if both sides have the other side's key.
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.46.0"
|
||||
version = "2.48.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-03-19
|
||||
2026-03-24
|
||||
21
spec.md
21
spec.md
@@ -39,9 +39,24 @@ Messages SHOULD be encrypted by the
|
||||
[Autocrypt](https://autocrypt.org/level1.html) standard;
|
||||
`prefer-encrypt=mutual` MAY be set by default.
|
||||
|
||||
Meta data (at least the subject and all chat-headers) SHOULD be encrypted
|
||||
by the [Protected Headers](https://tools.ietf.org/id/draft-autocrypt-lamps-protected-headers-02.html) standard.
|
||||
|
||||
Meta data SHOULD be encrypted
|
||||
by the [Header Protection](https://www.rfc-editor.org/rfc/rfc9788.html) standard
|
||||
with the following [Header Confidentiality Policy](https://www.rfc-editor.org/rfc/rfc9788.html#name-header-confidentiality-poli):
|
||||
```
|
||||
hcp_chat(name, val_in) → val_out:
|
||||
if lower(name) is 'from':
|
||||
assert that val_in is an RFC 5322 mailbox
|
||||
return the RFC 5322 addr-spec part of val_in
|
||||
else if lower(name) is 'to':
|
||||
return '"hidden-recipients": ;'
|
||||
else if lower(name) is 'date':
|
||||
return the UTC form of a random date within the last 7 days
|
||||
else if lower(name) is 'subject':
|
||||
return '[...]'
|
||||
else if lower(name) is in ['message-id', 'chat-is-post-message']:
|
||||
return val_in
|
||||
return null
|
||||
```
|
||||
|
||||
# Outgoing messages
|
||||
|
||||
|
||||
@@ -1249,13 +1249,11 @@ mod tests {
|
||||
let account1 = accounts.get_account(1).context("failed to get account 1")?;
|
||||
let account2 = accounts.get_account(2).context("failed to get account 2")?;
|
||||
|
||||
assert_eq!(stock_str::no_messages(&account1).await, "No messages.");
|
||||
assert_eq!(stock_str::no_messages(&account2).await, "No messages.");
|
||||
account1
|
||||
.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())
|
||||
.await?;
|
||||
assert_eq!(stock_str::no_messages(&account1).await, "foobar");
|
||||
assert_eq!(stock_str::no_messages(&account2).await, "foobar");
|
||||
assert_eq!(stock_str::no_messages(&account1), "No messages.");
|
||||
assert_eq!(stock_str::no_messages(&account2), "No messages.");
|
||||
account1.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())?;
|
||||
assert_eq!(stock_str::no_messages(&account1), "foobar");
|
||||
assert_eq!(stock_str::no_messages(&account2), "foobar");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
31
src/calls.rs
31
src/calls.rs
@@ -104,13 +104,11 @@ impl CallInfo {
|
||||
};
|
||||
|
||||
if self.is_incoming() {
|
||||
let incoming_call_str =
|
||||
stock_str::incoming_call(context, self.has_video_initially()).await;
|
||||
let incoming_call_str = stock_str::incoming_call(context, self.has_video_initially());
|
||||
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
|
||||
.await?;
|
||||
} else {
|
||||
let outgoing_call_str =
|
||||
stock_str::outgoing_call(context, self.has_video_initially()).await;
|
||||
let outgoing_call_str = stock_str::outgoing_call(context, self.has_video_initially());
|
||||
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
|
||||
.await?;
|
||||
}
|
||||
@@ -207,7 +205,7 @@ impl Context {
|
||||
);
|
||||
ensure!(!chat.is_self_talk(), "Cannot call self");
|
||||
|
||||
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially).await;
|
||||
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially);
|
||||
let mut call = Message {
|
||||
viewtype: Viewtype::Call,
|
||||
text: outgoing_call_str,
|
||||
@@ -286,11 +284,11 @@ impl Context {
|
||||
if call.is_incoming() {
|
||||
call.mark_as_ended(self).await?;
|
||||
markseen_msgs(self, vec![call_id]).await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
let declined_call_str = stock_str::declined_call(self);
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_canceled(self).await?;
|
||||
let canceled_call_str = stock_str::canceled_call(self).await;
|
||||
let canceled_call_str = stock_str::canceled_call(self);
|
||||
call.update_text(self, &canceled_call_str).await?;
|
||||
}
|
||||
} else {
|
||||
@@ -333,11 +331,11 @@ impl Context {
|
||||
if !call.is_accepted() && !call.is_ended() {
|
||||
if call.is_incoming() {
|
||||
call.mark_as_canceled(&context).await?;
|
||||
let missed_call_str = stock_str::missed_call(&context).await;
|
||||
let missed_call_str = stock_str::missed_call(&context);
|
||||
call.update_text(&context, &missed_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_ended(&context).await?;
|
||||
let canceled_call_str = stock_str::canceled_call(&context).await;
|
||||
let canceled_call_str = stock_str::canceled_call(&context);
|
||||
call.update_text(&context, &canceled_call_str).await?;
|
||||
}
|
||||
context.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
@@ -363,12 +361,12 @@ impl Context {
|
||||
|
||||
if call.is_incoming() {
|
||||
if call.is_stale() {
|
||||
let missed_call_str = stock_str::missed_call(self).await;
|
||||
let missed_call_str = stock_str::missed_call(self);
|
||||
call.update_text(self, &missed_call_str).await?;
|
||||
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
|
||||
} else {
|
||||
let incoming_call_str =
|
||||
stock_str::incoming_call(self, call.has_video_initially()).await;
|
||||
stock_str::incoming_call(self, call.has_video_initially());
|
||||
call.update_text(self, &incoming_call_str).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
|
||||
let can_call_me = match who_can_call_me(self).await? {
|
||||
@@ -409,8 +407,7 @@ impl Context {
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let outgoing_call_str =
|
||||
stock_str::outgoing_call(self, call.has_video_initially()).await;
|
||||
let outgoing_call_str = stock_str::outgoing_call(self, call.has_video_initially());
|
||||
call.update_text(self, &outgoing_call_str).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
}
|
||||
@@ -462,22 +459,22 @@ impl Context {
|
||||
if call.is_incoming() {
|
||||
if from_id == ContactId::SELF {
|
||||
call.mark_as_ended(self).await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
let declined_call_str = stock_str::declined_call(self);
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_canceled(self).await?;
|
||||
let missed_call_str = stock_str::missed_call(self).await;
|
||||
let missed_call_str = stock_str::missed_call(self);
|
||||
call.update_text(self, &missed_call_str).await?;
|
||||
}
|
||||
} else {
|
||||
// outgoing
|
||||
if from_id == ContactId::SELF {
|
||||
call.mark_as_canceled(self).await?;
|
||||
let canceled_call_str = stock_str::canceled_call(self).await;
|
||||
let canceled_call_str = stock_str::canceled_call(self);
|
||||
call.update_text(self, &canceled_call_str).await?;
|
||||
} else {
|
||||
call.mark_as_ended(self).await?;
|
||||
let declined_call_str = stock_str::declined_call(self).await;
|
||||
let declined_call_str = stock_str::declined_call(self);
|
||||
call.update_text(self, &declined_call_str).await?;
|
||||
}
|
||||
}
|
||||
|
||||
37
src/chat.rs
37
src/chat.rs
@@ -476,7 +476,7 @@ impl ChatId {
|
||||
|
||||
/// Adds message "Messages are end-to-end encrypted".
|
||||
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
|
||||
let text = stock_str::messages_e2ee_info_msg(context).await;
|
||||
let text = stock_str::messages_e2ee_info_msg(context);
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
self,
|
||||
@@ -669,7 +669,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
}
|
||||
|
||||
if chat.is_self_talk() {
|
||||
let mut msg = Message::new_text(stock_str::self_deleted_msg_body(context).await);
|
||||
let mut msg = Message::new_text(stock_str::self_deleted_msg_body(context));
|
||||
add_device_msg(context, None, Some(&mut msg)).await?;
|
||||
}
|
||||
chatlist_events::emit_chatlist_changed(context);
|
||||
@@ -1155,10 +1155,10 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
|
||||
pub async fn get_encryption_info(self, context: &Context) -> Result<String> {
|
||||
let chat = Chat::load_from_db(context, self).await?;
|
||||
if !chat.is_encrypted(context).await? {
|
||||
return Ok(stock_str::encr_none(context).await);
|
||||
return Ok(stock_str::encr_none(context));
|
||||
}
|
||||
|
||||
let mut ret = stock_str::messages_are_e2ee(context).await + "\n";
|
||||
let mut ret = stock_str::messages_are_e2ee(context) + "\n";
|
||||
|
||||
for &contact_id in get_chat_contacts(context, self)
|
||||
.await?
|
||||
@@ -1392,7 +1392,7 @@ impl Chat {
|
||||
.context(format!("Failed loading chat {chat_id} from database"))?;
|
||||
|
||||
if chat.id.is_archived_link() {
|
||||
chat.name = stock_str::archived_chats(context).await;
|
||||
chat.name = stock_str::archived_chats(context);
|
||||
} else {
|
||||
if chat.typ == Chattype::Single && chat.name.is_empty() {
|
||||
// chat.name is set to contact.display_name on changes,
|
||||
@@ -1416,9 +1416,9 @@ impl Chat {
|
||||
chat.name = chat_name;
|
||||
}
|
||||
if chat.param.exists(Param::Selftalk) {
|
||||
chat.name = stock_str::saved_messages(context).await;
|
||||
chat.name = stock_str::saved_messages(context);
|
||||
} else if chat.param.exists(Param::Devicetalk) {
|
||||
chat.name = stock_str::device_messages(context).await;
|
||||
chat.name = stock_str::device_messages(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2306,15 +2306,10 @@ pub(crate) async fn update_special_chat_names(context: &Context) -> Result<()> {
|
||||
update_special_chat_name(
|
||||
context,
|
||||
ContactId::DEVICE,
|
||||
stock_str::device_messages(context).await,
|
||||
)
|
||||
.await?;
|
||||
update_special_chat_name(
|
||||
context,
|
||||
ContactId::SELF,
|
||||
stock_str::saved_messages(context).await,
|
||||
stock_str::device_messages(context),
|
||||
)
|
||||
.await?;
|
||||
update_special_chat_name(context, ContactId::SELF, stock_str::saved_messages(context)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3068,7 +3063,7 @@ async fn donation_request_maybe(context: &Context) -> Result<()> {
|
||||
let ts = if ts == 0 || msg_cnt.await? < 100 {
|
||||
now.saturating_add(secs_between_checks)
|
||||
} else {
|
||||
let mut msg = Message::new_text(stock_str::donation_request(context).await);
|
||||
let mut msg = Message::new_text(stock_str::donation_request(context));
|
||||
add_device_msg(context, None, Some(&mut msg)).await?;
|
||||
i64::MAX
|
||||
};
|
||||
@@ -3622,10 +3617,10 @@ pub(crate) async fn create_group_ex(
|
||||
{
|
||||
let text = if !grpid.is_empty() {
|
||||
// Add "Others will only see this group after you sent a first message." message.
|
||||
stock_str::new_group_send_first_message(context).await
|
||||
stock_str::new_group_send_first_message(context)
|
||||
} else {
|
||||
// Add "Messages in this chat use classic email and are not encrypted." message.
|
||||
stock_str::chat_unencrypted_explanation(context).await
|
||||
stock_str::chat_unencrypted_explanation(context)
|
||||
};
|
||||
add_info_msg(context, chat_id, &text).await?;
|
||||
}
|
||||
@@ -4197,7 +4192,7 @@ async fn send_member_removal_msg(
|
||||
|
||||
if contact_id == ContactId::SELF {
|
||||
if chat.typ == Chattype::InBroadcast {
|
||||
msg.text = stock_str::msg_you_left_broadcast(context).await;
|
||||
msg.text = stock_str::msg_you_left_broadcast(context);
|
||||
} else {
|
||||
msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await;
|
||||
}
|
||||
@@ -4358,7 +4353,7 @@ async fn rename_ex(
|
||||
{
|
||||
msg.viewtype = Viewtype::Text;
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name).await
|
||||
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name)
|
||||
} else {
|
||||
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await
|
||||
};
|
||||
@@ -4423,7 +4418,7 @@ pub async fn set_chat_profile_image(
|
||||
chat.param.remove(Param::ProfileImage);
|
||||
msg.param.remove(Param::Arg);
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
stock_str::msg_broadcast_img_changed(context)
|
||||
} else {
|
||||
stock_str::msg_grp_img_deleted(context, ContactId::SELF).await
|
||||
};
|
||||
@@ -4437,7 +4432,7 @@ pub async fn set_chat_profile_image(
|
||||
chat.param.set(Param::ProfileImage, image_blob.as_name());
|
||||
msg.param.set(Param::Arg, image_blob.as_name());
|
||||
msg.text = if chat.typ == Chattype::OutBroadcast {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
stock_str::msg_broadcast_img_changed(context)
|
||||
} else {
|
||||
stock_str::msg_grp_img_changed(context, ContactId::SELF).await
|
||||
};
|
||||
|
||||
@@ -806,7 +806,7 @@ async fn test_self_talk() -> Result<()> {
|
||||
assert!(chat.visibility == ChatVisibility::Normal);
|
||||
assert!(!chat.is_device_talk());
|
||||
assert!(chat.can_send(&t).await?);
|
||||
assert_eq!(chat.name, stock_str::saved_messages(&t).await);
|
||||
assert_eq!(chat.name, stock_str::saved_messages(&t));
|
||||
assert!(chat.get_profile_image(&t).await?.is_some());
|
||||
|
||||
let msg_id = send_text_msg(&t, chat.id, "foo self".to_string()).await?;
|
||||
@@ -911,7 +911,7 @@ async fn test_add_device_msg_labelled() -> Result<()> {
|
||||
assert!(!chat.can_send(&t).await?);
|
||||
assert!(chat.why_cant_send(&t).await? == Some(CantSendReason::DeviceChat));
|
||||
|
||||
assert_eq!(chat.name, stock_str::device_messages(&t).await);
|
||||
assert_eq!(chat.name, stock_str::device_messages(&t));
|
||||
let device_msg_icon = chat.get_profile_image(&t).await?.unwrap();
|
||||
assert_eq!(
|
||||
device_msg_icon.metadata()?.len(),
|
||||
@@ -3335,7 +3335,12 @@ async fn test_chat_description(
|
||||
initial_description
|
||||
);
|
||||
|
||||
for description in ["This is a cool chat", "", "ä ẟ 😂"] {
|
||||
for description in [
|
||||
&"This<>is 'a' \"cool\" chat:/\\|?*".repeat(50),
|
||||
"multiple\nline\n\nbreaks\n\n\r\n.",
|
||||
"",
|
||||
"ä ẟ 😂",
|
||||
] {
|
||||
tcm.section(&format!(
|
||||
"Alice sets the chat description to '{description}'"
|
||||
));
|
||||
@@ -3792,7 +3797,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
assert_eq!(rcvd.chat_id, bob1_hello.chat_id);
|
||||
assert!(rcvd.is_info());
|
||||
assert_eq!(rcvd.get_info_type(), SystemMessage::MemberRemovedFromGroup);
|
||||
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1).await);
|
||||
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ impl Chatlist {
|
||||
Summary::new_with_reaction_details(context, &lastmsg, chat, lastcontact.as_ref()).await
|
||||
} else {
|
||||
Ok(Summary {
|
||||
text: stock_str::no_messages(context).await,
|
||||
text: stock_str::no_messages(context),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -648,7 +648,6 @@ mod tests {
|
||||
assert_eq!(chats.len(), 0);
|
||||
|
||||
t.set_stock_translation(StockMessage::SavedMessages, "test-1234-save".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let chats = Chatlist::try_load(&t, 0, Some("t-1234-s"), None)
|
||||
.await
|
||||
@@ -656,7 +655,6 @@ mod tests {
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
t.set_stock_translation(StockMessage::DeviceMessages, "test-5678-babbel".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let chats = Chatlist::try_load(&t, 0, Some("t-5678-b"), None)
|
||||
.await
|
||||
|
||||
@@ -246,8 +246,7 @@ async fn test_sync() -> Result<()> {
|
||||
alice1
|
||||
.get_config(Config::Selfavatar)
|
||||
.await?
|
||||
.filter(|path| path.ends_with(".png"))
|
||||
.is_some()
|
||||
.is_some_and(|path| path.ends_with(".png"))
|
||||
);
|
||||
alice0.set_config(Config::Selfavatar, None).await?;
|
||||
sync(&alice0, &alice1).await;
|
||||
@@ -305,16 +304,14 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
|
||||
alice1
|
||||
.get_config(Config::Selfavatar)
|
||||
.await?
|
||||
.filter(|path| path.ends_with(".jpg"))
|
||||
.is_some()
|
||||
.is_some_and(|path| path.ends_with(".jpg"))
|
||||
);
|
||||
sync(alice1, alice0).await;
|
||||
assert!(
|
||||
alice0
|
||||
.get_config(Config::Selfavatar)
|
||||
.await?
|
||||
.filter(|path| path.ends_with(".jpg"))
|
||||
.is_some()
|
||||
.is_some_and(|path| path.ends_with(".jpg"))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -146,7 +146,7 @@ impl Context {
|
||||
if let Err(err) = res.as_ref() {
|
||||
// We are using Anyhow's .context() and to show the
|
||||
// inner error, too, we need the {:#}:
|
||||
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}")).await;
|
||||
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
|
||||
progress!(self, 0, Some(error_msg.clone()));
|
||||
bail!(error_msg);
|
||||
} else {
|
||||
@@ -637,10 +637,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
let imap_session = match imap.connect(ctx, configuring).await {
|
||||
Ok(imap_session) => imap_session,
|
||||
Err(err) => {
|
||||
bail!(
|
||||
"{}",
|
||||
nicer_configuration_error(ctx, format!("{err:#}")).await
|
||||
);
|
||||
bail!("{}", nicer_configuration_error(ctx, format!("{err:#}")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -781,7 +778,7 @@ async fn get_autoconfig(
|
||||
None
|
||||
}
|
||||
|
||||
async fn nicer_configuration_error(context: &Context, e: String) -> String {
|
||||
fn nicer_configuration_error(context: &Context, e: String) -> String {
|
||||
if e.to_lowercase().contains("could not resolve")
|
||||
|| e.to_lowercase().contains("connection attempts")
|
||||
|| e.to_lowercase()
|
||||
@@ -790,7 +787,7 @@ async fn nicer_configuration_error(context: &Context, e: String) -> String {
|
||||
|| e.to_lowercase()
|
||||
.contains("failed to lookup address information")
|
||||
{
|
||||
return stock_str::error_no_network(context).await;
|
||||
return stock_str::error_no_network(context);
|
||||
}
|
||||
|
||||
e
|
||||
|
||||
@@ -688,7 +688,7 @@ impl Contact {
|
||||
.await?
|
||||
{
|
||||
if contact_id == ContactId::SELF {
|
||||
contact.name = stock_str::self_msg(context).await;
|
||||
contact.name = stock_str::self_msg(context);
|
||||
contact.authname = context
|
||||
.get_config(Config::Displayname)
|
||||
.await?
|
||||
@@ -705,9 +705,9 @@ impl Contact {
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
} else if contact_id == ContactId::DEVICE {
|
||||
contact.name = stock_str::device_messages(context).await;
|
||||
contact.name = stock_str::device_messages(context);
|
||||
contact.addr = ContactId::DEVICE_ADDR.to_string();
|
||||
contact.status = stock_str::device_messages_hint(context).await;
|
||||
contact.status = stock_str::device_messages_hint(context);
|
||||
}
|
||||
Ok(Some(contact))
|
||||
} else {
|
||||
@@ -1240,7 +1240,7 @@ ORDER BY c.origin>=? DESC, c.last_seen DESC, c.id DESC
|
||||
|
||||
if self_addr.contains(query)
|
||||
|| self_name.contains(query)
|
||||
|| self_name2.await.contains(query)
|
||||
|| self_name2.contains(query)
|
||||
{
|
||||
add_self = true;
|
||||
}
|
||||
@@ -1392,17 +1392,17 @@ WHERE addr=?
|
||||
.unwrap_or_default();
|
||||
|
||||
let Some(fingerprint_other) = contact.fingerprint() else {
|
||||
return Ok(stock_str::encr_none(context).await);
|
||||
return Ok(stock_str::encr_none(context));
|
||||
};
|
||||
let fingerprint_other = fingerprint_other.to_string();
|
||||
|
||||
let stock_message = if contact.public_key(context).await?.is_some() {
|
||||
stock_str::messages_are_e2ee(context).await
|
||||
stock_str::messages_are_e2ee(context)
|
||||
} else {
|
||||
stock_str::encr_none(context).await
|
||||
stock_str::encr_none(context)
|
||||
};
|
||||
|
||||
let finger_prints = stock_str::finger_prints(context).await;
|
||||
let finger_prints = stock_str::finger_prints(context);
|
||||
let mut ret = format!("{stock_message}\n{finger_prints}:");
|
||||
|
||||
let fingerprint_self = load_self_public_key(context)
|
||||
@@ -1412,7 +1412,7 @@ WHERE addr=?
|
||||
if addr < contact.addr {
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
&stock_str::self_msg(context).await,
|
||||
&stock_str::self_msg(context),
|
||||
&addr,
|
||||
&fingerprint_self,
|
||||
);
|
||||
@@ -1431,7 +1431,7 @@ WHERE addr=?
|
||||
);
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
&stock_str::self_msg(context).await,
|
||||
&stock_str::self_msg(context),
|
||||
&addr,
|
||||
&fingerprint_self,
|
||||
);
|
||||
|
||||
@@ -282,7 +282,7 @@ async fn test_add_or_lookup() {
|
||||
|
||||
// check SELF
|
||||
let contact = Contact::get_by_id(&t, ContactId::SELF).await.unwrap();
|
||||
assert_eq!(contact.get_name(), stock_str::self_msg(&t).await);
|
||||
assert_eq!(contact.get_name(), stock_str::self_msg(&t));
|
||||
assert_eq!(contact.get_addr(), "alice@example.org");
|
||||
assert!(!contact.is_blocked());
|
||||
}
|
||||
|
||||
@@ -266,15 +266,11 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
|
||||
}
|
||||
}
|
||||
}
|
||||
"b" | "strong" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"i" | "em" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"i" | "em" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"blockquote" => pop_tag(&mut dehtml.blockquotes_since_blockquote),
|
||||
_ => {}
|
||||
@@ -341,15 +337,11 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
|
||||
}
|
||||
}
|
||||
}
|
||||
"b" | "strong" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "*";
|
||||
}
|
||||
"i" | "em" => {
|
||||
if dehtml.get_add_text() != AddText::No {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"i" | "em" if dehtml.get_add_text() != AddText::No => {
|
||||
*dehtml.get_buf() += "_";
|
||||
}
|
||||
"blockquote" => dehtml.blockquotes_since_blockquote += 1,
|
||||
_ => {}
|
||||
|
||||
@@ -165,6 +165,7 @@ pub(crate) async fn download_msg(
|
||||
|
||||
let Some((server_uid, server_folder, msg_transport_id)) = row else {
|
||||
// No IMAP record found, we don't know the UID and folder.
|
||||
delete_from_available_post_msgs(context, &rfc724_mid).await?;
|
||||
return Err(anyhow!(
|
||||
"IMAP location for {rfc724_mid:?} post-message is unknown"
|
||||
));
|
||||
@@ -326,22 +327,25 @@ pub(crate) async fn download_known_post_messages_without_pre_message(
|
||||
})
|
||||
.await?;
|
||||
for rfc724_mid in &rfc724_mids {
|
||||
if !msg_is_downloaded_for(context, rfc724_mid).await? {
|
||||
// Download the Post-Message unconditionally,
|
||||
// because the Pre-Message got lost.
|
||||
// The message may be in the wrong order,
|
||||
// but at least we have it at all.
|
||||
let res = download_msg(context, rfc724_mid.clone(), session).await;
|
||||
if let Ok(Some(())) = res {
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
}
|
||||
if let Err(err) = res {
|
||||
warn!(
|
||||
context,
|
||||
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
|
||||
err
|
||||
);
|
||||
}
|
||||
if msg_is_downloaded_for(context, rfc724_mid).await? {
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download the Post-Message unconditionally,
|
||||
// because the Pre-Message got lost.
|
||||
// The message may be in the wrong order,
|
||||
// but at least we have it at all.
|
||||
let res = download_msg(context, rfc724_mid.clone(), session).await;
|
||||
if let Ok(Some(())) = res {
|
||||
delete_from_available_post_msgs(context, rfc724_mid).await?;
|
||||
}
|
||||
if let Err(err) = res {
|
||||
warn!(
|
||||
context,
|
||||
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -125,7 +125,6 @@ pub enum HeaderDef {
|
||||
/// [Autocrypt](https://autocrypt.org/) header.
|
||||
Autocrypt,
|
||||
AutocryptGossip,
|
||||
AutocryptSetupMessage,
|
||||
SecureJoin,
|
||||
|
||||
/// Deprecated header containing Group-ID in `vg-request-with-auth` message.
|
||||
|
||||
114
src/imap.rs
114
src/imap.rs
@@ -296,6 +296,11 @@ impl Imap {
|
||||
Ok(imap)
|
||||
}
|
||||
|
||||
/// Returns transport ID of the IMAP client.
|
||||
pub fn transport_id(&self) -> u32 {
|
||||
self.transport_id
|
||||
}
|
||||
|
||||
/// Connects to IMAP server and returns a new IMAP session.
|
||||
///
|
||||
/// Calling this function is not enough to perform IMAP operations. Use [`Imap::prepare`]
|
||||
@@ -318,7 +323,8 @@ impl Imap {
|
||||
if !ratelimit_duration.is_zero() {
|
||||
warn!(
|
||||
context,
|
||||
"IMAP got rate limited, waiting for {} until can connect.",
|
||||
"Transport {}: IMAP got rate limited, waiting for {} until can connect.",
|
||||
self.transport_id,
|
||||
duration_to_str(ratelimit_duration),
|
||||
);
|
||||
let interrupted = async {
|
||||
@@ -330,12 +336,16 @@ impl Imap {
|
||||
if interrupted {
|
||||
info!(
|
||||
context,
|
||||
"Connecting to IMAP without waiting for ratelimit due to interrupt."
|
||||
"Transport {}: Connecting to IMAP without waiting for ratelimit due to interrupt.",
|
||||
self.transport_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!(context, "Connecting to IMAP server.");
|
||||
info!(
|
||||
context,
|
||||
"Transport {}: Connecting to IMAP server.", self.transport_id
|
||||
);
|
||||
self.connectivity.set_connecting(context);
|
||||
|
||||
self.conn_last_try = tools::Time::now();
|
||||
@@ -350,7 +360,10 @@ impl Imap {
|
||||
let login_params = prioritize_server_login_params(&context.sql, &self.lp, "imap").await?;
|
||||
let mut first_error = None;
|
||||
for lp in login_params {
|
||||
info!(context, "IMAP trying to connect to {}.", &lp.connection);
|
||||
info!(
|
||||
context,
|
||||
"Transport {}: IMAP trying to connect to {}.", self.transport_id, &lp.connection
|
||||
);
|
||||
let connection_candidate = lp.connection.clone();
|
||||
let client = match Client::connect(
|
||||
context,
|
||||
@@ -398,7 +411,10 @@ impl Imap {
|
||||
let resync_request_sender = self.resync_request_sender.clone();
|
||||
|
||||
let session = if capabilities.can_compress {
|
||||
info!(context, "Enabling IMAP compression.");
|
||||
info!(
|
||||
context,
|
||||
"Transport {}: Enabling IMAP compression.", self.transport_id
|
||||
);
|
||||
let compressed_session = session
|
||||
.compress(|s| {
|
||||
let session_stream: Box<dyn SessionStream> = Box::new(s);
|
||||
@@ -431,15 +447,21 @@ impl Imap {
|
||||
lp.user
|
||||
)));
|
||||
self.connectivity.set_preparing(context);
|
||||
info!(context, "Successfully logged into IMAP server.");
|
||||
info!(
|
||||
context,
|
||||
"Transport {}: Successfully logged into IMAP server.", self.transport_id
|
||||
);
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
Err(err) => {
|
||||
let imap_user = lp.user.to_owned();
|
||||
let message = stock_str::cannot_login(context, &imap_user).await;
|
||||
let message = stock_str::cannot_login(context, &imap_user);
|
||||
|
||||
warn!(context, "IMAP failed to login: {err:#}.");
|
||||
warn!(
|
||||
context,
|
||||
"Transport {}: IMAP failed to login: {err:#}.", self.transport_id
|
||||
);
|
||||
first_error.get_or_insert(format_err!("{message} ({err:#})"));
|
||||
|
||||
// If it looks like the password is wrong, send a notification:
|
||||
@@ -458,7 +480,11 @@ impl Imap {
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(context, "Failed to add device message: {e:#}.");
|
||||
warn!(
|
||||
context,
|
||||
"Transport {}: Failed to add device message: {e:#}.",
|
||||
self.transport_id
|
||||
);
|
||||
} else {
|
||||
context
|
||||
.set_config_internal(Config::NotifyAboutWrongPw, None)
|
||||
@@ -520,10 +546,21 @@ impl Imap {
|
||||
bail!("IMAP operation attempted while it is torn down");
|
||||
}
|
||||
|
||||
let transport_id = session.transport_id();
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: fetch_move_delete start."
|
||||
);
|
||||
|
||||
let msgs_fetched = self
|
||||
.fetch_new_messages(context, session, watch_folder, folder_meaning)
|
||||
.await
|
||||
.context("fetch_new_messages")?;
|
||||
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: fetch_move_delete finished fetch_new_messages."
|
||||
);
|
||||
if msgs_fetched && context.get_config_delete_device_after().await?.is_some() {
|
||||
// New messages were fetched and shall be deleted later, restart ephemeral loop.
|
||||
// Note that the `Config::DeleteDeviceAfter` timer starts as soon as the messages are
|
||||
@@ -551,26 +588,45 @@ impl Imap {
|
||||
folder: &str,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<bool> {
|
||||
let transport_id = session.transport_id();
|
||||
|
||||
if should_ignore_folder(context, folder, folder_meaning).await? {
|
||||
info!(context, "Not fetching from {folder:?}.");
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: Not fetching from {folder:?}."
|
||||
);
|
||||
session.new_mail = false;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: fetch_new_messages selects folder {folder:?}."
|
||||
);
|
||||
let folder_exists = session
|
||||
.select_with_uidvalidity(context, folder)
|
||||
.await
|
||||
.with_context(|| format!("Failed to select folder {folder:?}"))?;
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: fetch_new_messages selected folder {folder:?}."
|
||||
);
|
||||
|
||||
if !session.new_mail {
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: No new emails in folder {folder:?}."
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
// Make sure not to return before setting new_mail to false
|
||||
// Otherwise, we will skip IDLE and go into an infinite loop
|
||||
session.new_mail = false;
|
||||
|
||||
if !folder_exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if !session.new_mail {
|
||||
info!(context, "No new emails in folder {folder:?}.");
|
||||
return Ok(false);
|
||||
}
|
||||
session.new_mail = false;
|
||||
|
||||
let mut read_cnt = 0;
|
||||
loop {
|
||||
let (n, fetch_more) = self
|
||||
@@ -1088,6 +1144,7 @@ impl Session {
|
||||
}
|
||||
|
||||
let transport_id = self.transport_id();
|
||||
info!(context, "Transport {transport_id}: Storing seen flags.");
|
||||
let rows = context
|
||||
.sql
|
||||
.query_map_vec(
|
||||
@@ -1122,13 +1179,15 @@ impl Session {
|
||||
} else if let Err(err) = self.add_flag_finalized_with_set(&uid_set, "\\Seen").await {
|
||||
warn!(
|
||||
context,
|
||||
"Cannot mark messages {uid_set} in {folder} as seen, will retry later: {err:#}."
|
||||
"Transport {transport_id}: Cannot mark messages {uid_set} in {folder} as seen, will retry later: {err:#}."
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
info!(
|
||||
context,
|
||||
"Marked messages {} in folder {} as seen.", uid_set, folder
|
||||
"Transport {transport_id}: Marked messages {} in folder {} as seen.",
|
||||
uid_set,
|
||||
folder
|
||||
);
|
||||
}
|
||||
context
|
||||
@@ -1143,6 +1202,10 @@ impl Session {
|
||||
.await
|
||||
.context("Cannot remove messages marked as seen from imap_markseen table")?;
|
||||
}
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: Finished storing seen flags."
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1237,6 +1300,7 @@ impl Session {
|
||||
// have been modified while our request was in progress.
|
||||
// We may or may not have these new flags as a part of the response,
|
||||
// so better skip next IDLE and do another round of flag synchronization.
|
||||
info!(context, "Got unsolicited fetch, will skip idle");
|
||||
self.new_mail = true;
|
||||
}
|
||||
|
||||
@@ -1482,9 +1546,10 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let transport_id = self.transport_id();
|
||||
info!(
|
||||
context,
|
||||
"Server supports metadata, retrieving server comment and admin contact."
|
||||
"Transport {transport_id}: Server supports metadata, retrieving server comment and admin contact."
|
||||
);
|
||||
|
||||
let mut comment = None;
|
||||
@@ -1517,7 +1582,8 @@ impl Session {
|
||||
} else {
|
||||
warn!(
|
||||
context,
|
||||
"Got invalid URL from iroh relay metadata: {:?}.", value
|
||||
"Transport {transport_id}: Got invalid URL from iroh relay metadata: {:?}.",
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1546,6 +1612,7 @@ impl Session {
|
||||
create_fallback_ice_servers()
|
||||
};
|
||||
|
||||
info!(context, "Transport {transport_id}: Got IMAP metadata.");
|
||||
*lock = Some(ServerMetadata {
|
||||
comment,
|
||||
admin,
|
||||
@@ -1562,11 +1629,18 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let transport_id = self.transport_id();
|
||||
|
||||
let Some(device_token) = context.push_subscriber.device_token().await else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if self.can_metadata() && self.can_push() {
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: Subscribing for push notifications."
|
||||
);
|
||||
|
||||
let old_encrypted_device_token =
|
||||
context.get_config(Config::EncryptedDeviceToken).await?;
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ impl Session {
|
||||
idle_interrupt_receiver: Receiver<()>,
|
||||
folder: &str,
|
||||
) -> Result<Self> {
|
||||
let transport_id = self.transport_id();
|
||||
|
||||
self.select_with_uidvalidity(context, folder).await?;
|
||||
|
||||
if self.drain_unsolicited_responses(context)? {
|
||||
@@ -36,13 +38,16 @@ impl Session {
|
||||
if self.new_mail {
|
||||
info!(
|
||||
context,
|
||||
"Skipping IDLE in {folder:?} because there may be new mail."
|
||||
"Transport {transport_id}: Skipping IDLE in {folder:?} because there may be new mail."
|
||||
);
|
||||
return Ok(self);
|
||||
}
|
||||
|
||||
if let Ok(()) = idle_interrupt_receiver.try_recv() {
|
||||
info!(context, "Skip IDLE in {folder:?} because we got interrupt.");
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: Skip IDLE in {folder:?} because we got interrupt."
|
||||
);
|
||||
return Ok(self);
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ impl Session {
|
||||
|
||||
info!(
|
||||
context,
|
||||
"IDLE entering wait-on-remote state in folder {folder:?}."
|
||||
"Transport {transport_id}: IDLE entering wait-on-remote state in folder {folder:?}."
|
||||
);
|
||||
|
||||
// Spawn a task to relay interrupts from `idle_interrupt_receiver`
|
||||
|
||||
@@ -71,13 +71,18 @@ impl ImapSession {
|
||||
self.select(folder).await
|
||||
};
|
||||
|
||||
let transport_id = self.transport_id();
|
||||
|
||||
// <https://tools.ietf.org/html/rfc3501#section-6.3.1>
|
||||
// says that if the server reports select failure we are in
|
||||
// authenticated (not-select) state.
|
||||
|
||||
match res {
|
||||
Ok(mailbox) => {
|
||||
info!(context, "Selected folder {folder:?}.");
|
||||
info!(
|
||||
context,
|
||||
"Transport {transport_id}: Selected folder {folder:?}."
|
||||
);
|
||||
self.selected_folder = Some(folder.to_string());
|
||||
self.selected_mailbox = Some(mailbox);
|
||||
Ok(NewlySelected::Yes)
|
||||
|
||||
@@ -208,7 +208,7 @@ impl BackupProvider {
|
||||
info!(context, "Received backup reception acknowledgement.");
|
||||
context.emit_event(EventType::ImexProgress(1000));
|
||||
|
||||
let mut msg = Message::new_text(backup_transfer_msg_body(&context).await);
|
||||
let mut msg = Message::new_text(backup_transfer_msg_body(&context));
|
||||
add_device_msg(&context, None, Some(&mut msg)).await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -288,13 +288,13 @@ pub async fn send_locations_to_chat(
|
||||
)
|
||||
.await?;
|
||||
if 0 != seconds && !is_sending_locations_before {
|
||||
let mut msg = Message::new_text(stock_str::msg_location_enabled(context).await);
|
||||
let mut msg = Message::new_text(stock_str::msg_location_enabled(context));
|
||||
msg.param.set_cmd(SystemMessage::LocationStreamingEnabled);
|
||||
chat::send_msg(context, chat_id, &mut msg)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
} else if 0 == seconds && is_sending_locations_before {
|
||||
let stock_str = stock_str::msg_location_disabled(context).await;
|
||||
let stock_str = stock_str::msg_location_disabled(context);
|
||||
chat::add_info_msg(context, chat_id, &stock_str).await?;
|
||||
}
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
@@ -852,7 +852,7 @@ async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
|
||||
.await
|
||||
.context("failed to disable location streaming")?;
|
||||
|
||||
let stock_str = stock_str::msg_location_disabled(context).await;
|
||||
let stock_str = stock_str::msg_location_disabled(context);
|
||||
chat::add_info_msg(context, chat_id, &stock_str).await?;
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat_id);
|
||||
|
||||
@@ -598,7 +598,7 @@ impl Message {
|
||||
|
||||
if let Some(msg) = &mut msg {
|
||||
msg.additional_text =
|
||||
Self::get_additional_text(context, msg.download_state, &msg.param).await?;
|
||||
Self::get_additional_text(context, msg.download_state, &msg.param)?;
|
||||
}
|
||||
|
||||
Ok(msg)
|
||||
@@ -607,7 +607,7 @@ impl Message {
|
||||
/// Returns additional text which is appended to the message's text field
|
||||
/// when it is loaded from the database.
|
||||
/// Currently this is used to add infomation to pre-messages of what the download will be and how large it is
|
||||
async fn get_additional_text(
|
||||
fn get_additional_text(
|
||||
context: &Context,
|
||||
download_state: DownloadState,
|
||||
param: &Params,
|
||||
@@ -630,7 +630,7 @@ impl Message {
|
||||
return match viewtype {
|
||||
Viewtype::File => Ok(format!(" [{file_name} – {file_size}]")),
|
||||
_ => {
|
||||
let translated_viewtype = viewtype.to_locale_string(context).await;
|
||||
let translated_viewtype = viewtype.to_locale_string(context);
|
||||
Ok(format!(" [{translated_viewtype} – {file_size}]"))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{SystemMessage, is_hidden};
|
||||
use crate::param::Param;
|
||||
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
|
||||
use crate::pgp::{SeipdVersion, addresses_from_public_key};
|
||||
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::{
|
||||
@@ -732,7 +732,7 @@ impl MimeFactory {
|
||||
Some(name) => name,
|
||||
None => context.get_config(Config::Addr).await?.unwrap_or_default(),
|
||||
};
|
||||
stock_str::subject_for_new_contact(context, self_name).await
|
||||
stock_str::subject_for_new_contact(context, self_name)
|
||||
}
|
||||
Loaded::Mdn { .. } => "Receipt Notification".to_string(), // untranslated to no reveal sender's language
|
||||
};
|
||||
@@ -1176,14 +1176,13 @@ impl MimeFactory {
|
||||
} else {
|
||||
// Asymmetric encryption
|
||||
|
||||
let seipd_version = if encryption_pubkeys.is_empty() {
|
||||
// If message is sent only to self,
|
||||
// use v2 SEIPD.
|
||||
// Use SEIPDv2 if all recipients support it.
|
||||
let seipd_version = if encryption_pubkeys
|
||||
.iter()
|
||||
.all(|(_addr, pubkey)| pubkey_supports_seipdv2(pubkey))
|
||||
{
|
||||
SeipdVersion::V2
|
||||
} else {
|
||||
// If message is sent to others,
|
||||
// they may not support v2 SEIPD yet,
|
||||
// so use v1 SEIPD.
|
||||
SeipdVersion::V1
|
||||
};
|
||||
|
||||
@@ -1525,7 +1524,7 @@ impl MimeFactory {
|
||||
let description = chat::get_chat_description(context, chat.id).await?;
|
||||
headers.push((
|
||||
"Chat-Group-Description",
|
||||
mail_builder::headers::text::Text::new(description.clone()).into(),
|
||||
mail_builder::headers::raw::Raw::new(b_encode(&description)).into(),
|
||||
));
|
||||
if let Some(ts) = chat.param.get_i64(Param::GroupDescriptionTimestamp) {
|
||||
headers.push((
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use deltachat_contact_tools::ContactAddress;
|
||||
use mail_builder::headers::Header;
|
||||
use mailparse::{MailHeaderMap, addrparse_header};
|
||||
use pgp::armor;
|
||||
use pgp::packet::{Packet, PacketParser};
|
||||
use std::io::BufReader;
|
||||
use std::str;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -11,7 +14,7 @@ use crate::chat::{
|
||||
};
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::constants;
|
||||
use crate::contact::Origin;
|
||||
use crate::contact::{Origin, import_vcard};
|
||||
use crate::headerdef::HeaderDef;
|
||||
use crate::message;
|
||||
use crate::mimeparser::MimeMessage;
|
||||
@@ -877,3 +880,85 @@ async fn test_no_empty_to_header() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses ASCII-armored message and checks that it only has PKESK and SEIPD packets.
|
||||
///
|
||||
/// Panics if SEIPD packets are not of expected version.
|
||||
fn assert_seipd_version(payload: &str, version: usize) {
|
||||
let cursor = Cursor::new(payload);
|
||||
let dearmor = armor::Dearmor::new(cursor);
|
||||
let packet_parser = PacketParser::new(BufReader::new(dearmor));
|
||||
for packet in packet_parser {
|
||||
match packet.unwrap() {
|
||||
Packet::PublicKeyEncryptedSessionKey(_pkesk) => {}
|
||||
Packet::SymEncryptedProtectedData(seipd) => {
|
||||
assert_eq!(seipd.version(), version);
|
||||
}
|
||||
packet => {
|
||||
panic!("Unexpected packet {:?}", packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests that messages between two test accounts use SEIPDv2 and not SEIPDv1.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_use_seipdv2() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = alice.create_chat_id(bob).await;
|
||||
let sent = alice.send_text(alice_chat_id, "Hello!").await;
|
||||
assert_seipd_version(&sent.payload, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that messages to keys that don't advertise SEIPDv2 support
|
||||
/// are sent using SEIPDv1.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_fallback_to_seipdv1() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let charlie = &tcm.charlie().await;
|
||||
|
||||
// vCard of Alice with no SEIPDv2 feature advertised in the key.
|
||||
let alice_vcard = "BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
EMAIL:alice@example.org
|
||||
FN:Alice
|
||||
KEY:data:application/pgp-keys;base64,mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5C0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFggAOBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEGSwj2Gp7ZRDE3oA/i4MCyDMTsjWqDZoQwX/A/GoTO2/V0wKPhjJJy/8m2pMAPkBjOnGOtx2SZpQvJGTa9h804RY6iDrRuI8A/8tEEXAA7g4BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp01JrRe6Xqy22HQMBCAeIeAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsMAAoJEGSwj2Gp7ZRDLo8BAObE8GnsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIyVfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp3jsMAg==
|
||||
REV:20250412T195751Z
|
||||
END:VCARD";
|
||||
let contact_ids = import_vcard(bob, alice_vcard).await.unwrap();
|
||||
let alice_contact_id = contact_ids[0];
|
||||
let chat_id = ChatId::create_for_contact(bob, alice_contact_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Bob sends a message to Alice with SEIPDv1 packet.
|
||||
let sent = bob.send_text(chat_id, "Hello!").await;
|
||||
assert_seipd_version(&sent.payload, 1);
|
||||
|
||||
// Bob creates a group with Alice and Charlie.
|
||||
// Sending a message there should also use SEIPDv1
|
||||
// because for Bob it looks like Alice does not support SEIPDv2.
|
||||
let charlie_contact_id = bob.add_or_lookup_contact_id(charlie).await;
|
||||
let group_id = create_group(bob, "groupname").await.unwrap();
|
||||
chat::add_contact_to_chat(bob, group_id, alice_contact_id).await?;
|
||||
chat::add_contact_to_chat(bob, group_id, charlie_contact_id).await?;
|
||||
|
||||
let sent = bob.send_text(group_id, "Hello!").await;
|
||||
assert_seipd_version(&sent.payload, 1);
|
||||
|
||||
// Bob gets a new key of Alice via new vCard
|
||||
// and learns that Alice supports SEIPDv2.
|
||||
assert_eq!(bob.add_or_lookup_contact_id(alice).await, alice_contact_id);
|
||||
|
||||
let sent = bob.send_text(group_id, "Hello again with SEIPDv2!").await;
|
||||
assert_seipd_version(&sent.payload, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -262,8 +262,6 @@ pub enum SystemMessage {
|
||||
GroupDescriptionChanged = 70,
|
||||
}
|
||||
|
||||
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
|
||||
|
||||
impl MimeMessage {
|
||||
/// Parse a mime message.
|
||||
///
|
||||
@@ -745,20 +743,8 @@ impl MimeMessage {
|
||||
}
|
||||
|
||||
/// Parses system messages.
|
||||
fn parse_system_message_headers(&mut self, context: &Context) {
|
||||
if self.get_header(HeaderDef::AutocryptSetupMessage).is_some() && !self.incoming {
|
||||
self.parts.retain(|part| {
|
||||
part.mimetype
|
||||
.as_ref()
|
||||
.is_none_or(|mimetype| mimetype.as_ref() == MIME_AC_SETUP_FILE)
|
||||
});
|
||||
|
||||
if self.parts.len() == 1 {
|
||||
self.is_system_message = SystemMessage::AutocryptSetupMessage;
|
||||
} else {
|
||||
warn!(context, "could not determine ASM mime-part");
|
||||
}
|
||||
} else if let Some(value) = self.get_header(HeaderDef::ChatContent) {
|
||||
fn parse_system_message_headers(&mut self) {
|
||||
if let Some(value) = self.get_header(HeaderDef::ChatContent) {
|
||||
if value == "location-streaming-enabled" {
|
||||
self.is_system_message = SystemMessage::LocationStreamingEnabled;
|
||||
} else if value == "ephemeral-timer-changed" {
|
||||
@@ -908,7 +894,7 @@ impl MimeMessage {
|
||||
}
|
||||
|
||||
async fn parse_headers(&mut self, context: &Context) -> Result<()> {
|
||||
self.parse_system_message_headers(context);
|
||||
self.parse_system_message_headers();
|
||||
self.parse_avatar_headers(context)?;
|
||||
self.parse_videochat_headers();
|
||||
if self.delivery_report.is_none() {
|
||||
|
||||
@@ -881,7 +881,7 @@ fn merge_with_cache(
|
||||
) -> Vec<SocketAddr> {
|
||||
let rest = resolved_addrs.split_off(std::cmp::min(resolved_addrs.len(), 2));
|
||||
|
||||
for addr in cache.into_iter().chain(rest.into_iter()) {
|
||||
for addr in cache.into_iter().chain(rest) {
|
||||
if !resolved_addrs.contains(&addr) {
|
||||
resolved_addrs.push(addr);
|
||||
if resolved_addrs.len() >= 10 {
|
||||
|
||||
35
src/pgp.rs
35
src/pgp.rs
@@ -128,13 +128,7 @@ pub async fn pk_encrypt(
|
||||
hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime(
|
||||
pgp::types::Timestamp::now(),
|
||||
))?);
|
||||
// Test "elena" uses old Delta Chat.
|
||||
let skip = private_key_for_signing.dc_fingerprint().hex()
|
||||
== "B86586B6DEF437D674BFAFC02A6B2EBC633B9E82";
|
||||
for key in &public_keys_for_encryption {
|
||||
if skip {
|
||||
break;
|
||||
}
|
||||
let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint());
|
||||
let subpkt = match private_key_for_signing.version() < KeyVersion::V6 {
|
||||
true => Subpacket::regular(data)?,
|
||||
@@ -501,6 +495,35 @@ pub(crate) fn addresses_from_public_key(public_key: &SignedPublicKey) -> Option<
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns true if public key advertises SEIPDv2 feature.
|
||||
pub(crate) fn pubkey_supports_seipdv2(public_key: &SignedPublicKey) -> bool {
|
||||
// If any Direct Key Signature or any User ID signature has SEIPDv2 feature,
|
||||
// assume that recipient can handle SEIPDv2.
|
||||
//
|
||||
// Third-party User ID signatures are dropped during certificate merging.
|
||||
// We don't check if the User ID is primary User ID.
|
||||
// Primary User ID is preferred during merging
|
||||
// and if some key has only non-primary User ID
|
||||
// it is acceptable. It is anyway unlikely that SEIPDv2
|
||||
// is advertised in a key without DKS or primary User ID.
|
||||
public_key
|
||||
.details
|
||||
.direct_signatures
|
||||
.iter()
|
||||
.chain(
|
||||
public_key
|
||||
.details
|
||||
.users
|
||||
.iter()
|
||||
.flat_map(|user| user.signatures.iter()),
|
||||
)
|
||||
.any(|signature| {
|
||||
signature
|
||||
.features()
|
||||
.is_some_and(|features| features.seipd_v2())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -136,6 +136,7 @@ impl PushSubscriber {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
info!(context, "Subscribing for heartbeat notifications.");
|
||||
if http::post_string(
|
||||
context,
|
||||
"https://notifications.delta.chat/register",
|
||||
@@ -143,6 +144,7 @@ impl PushSubscriber {
|
||||
)
|
||||
.await?
|
||||
{
|
||||
info!(context, "Subscribed for heartbeat notifications.");
|
||||
state.heartbeat_subscribed = true;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -111,10 +111,10 @@ async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Resu
|
||||
|
||||
let qrcode_description = match chat.typ {
|
||||
crate::constants::Chattype::Group => {
|
||||
stock_str::secure_join_group_qr_description(context, &chat).await
|
||||
stock_str::secure_join_group_qr_description(context, &chat)
|
||||
}
|
||||
crate::constants::Chattype::OutBroadcast => {
|
||||
stock_str::secure_join_broadcast_qr_description(context, &chat).await
|
||||
stock_str::secure_join_broadcast_qr_description(context, &chat)
|
||||
}
|
||||
_ => bail!("Unexpected chat type {}", chat.typ),
|
||||
};
|
||||
@@ -132,7 +132,7 @@ async fn generate_verification_qr(context: &Context) -> Result<String> {
|
||||
let (avatar, displayname, addr, color) = self_info(context).await?;
|
||||
|
||||
inner_generate_secure_join_qr_code(
|
||||
&stock_str::setup_contact_qr_description(context, &displayname, &addr).await,
|
||||
&stock_str::setup_contact_qr_description(context, &displayname, &addr),
|
||||
&securejoin::get_securejoin_qr(context, None).await?,
|
||||
&color,
|
||||
avatar,
|
||||
|
||||
24
src/quota.rs
24
src/quota.rs
@@ -109,10 +109,9 @@ impl Context {
|
||||
/// called.
|
||||
pub(crate) async fn quota_needs_update(&self, transport_id: u32, ratelimit_secs: u64) -> bool {
|
||||
let quota = self.quota.read().await;
|
||||
quota
|
||||
.get(&transport_id)
|
||||
.filter(|quota| time_elapsed("a.modified) < Duration::from_secs(ratelimit_secs))
|
||||
.is_none()
|
||||
quota.get(&transport_id).is_none_or(|quota| {
|
||||
time_elapsed("a.modified) >= Duration::from_secs(ratelimit_secs)
|
||||
})
|
||||
}
|
||||
|
||||
/// Updates `quota.recent`, sets `quota.modified` to the current time
|
||||
@@ -124,11 +123,15 @@ impl Context {
|
||||
/// in case for some providers the quota is always at ~100%
|
||||
/// and new space is allocated as needed.
|
||||
pub(crate) async fn update_recent_quota(&self, session: &mut ImapSession) -> Result<()> {
|
||||
let transport_id = session.transport_id();
|
||||
|
||||
info!(self, "Transport {transport_id}: Updating quota.");
|
||||
|
||||
let quota = if session.can_check_quota() {
|
||||
let folders = get_watched_folders(self).await?;
|
||||
get_unique_quota_roots_and_usage(session, folders).await
|
||||
} else {
|
||||
Err(anyhow!(stock_str::not_supported_by_provider(self).await))
|
||||
Err(anyhow!(stock_str::not_supported_by_provider(self)))
|
||||
};
|
||||
|
||||
if let Ok(quota) = "a {
|
||||
@@ -143,26 +146,29 @@ impl Context {
|
||||
Some(&highest.to_string()),
|
||||
)
|
||||
.await?;
|
||||
let mut msg =
|
||||
Message::new_text(stock_str::quota_exceeding(self, highest).await);
|
||||
let mut msg = Message::new_text(stock_str::quota_exceeding(self, highest));
|
||||
add_device_msg_with_importance(self, None, Some(&mut msg), true).await?;
|
||||
} else if highest <= QUOTA_ALLCLEAR_PERCENTAGE {
|
||||
self.set_config_internal(Config::QuotaExceeding, None)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(self, "cannot get highest quota usage: {:#}", err),
|
||||
Err(err) => warn!(
|
||||
self,
|
||||
"Transport {transport_id}: Cannot get highest quota usage: {err:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
self.quota.write().await.insert(
|
||||
session.transport_id(),
|
||||
transport_id,
|
||||
QuotaInfo {
|
||||
recent: quota,
|
||||
modified: tools::Time::now(),
|
||||
},
|
||||
);
|
||||
|
||||
info!(self, "Transport {transport_id}: Updated quota.");
|
||||
self.emit_event(EventType::ConnectivityChanged);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -347,8 +347,7 @@ impl Chat {
|
||||
if self
|
||||
.param
|
||||
.get_i64(Param::LastReactionTimestamp)
|
||||
.filter(|&reaction_timestamp| reaction_timestamp > timestamp)
|
||||
.is_none()
|
||||
.is_none_or(|reaction_timestamp| reaction_timestamp <= timestamp)
|
||||
{
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -879,8 +879,7 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
|
||||
let instance = if mime_parser
|
||||
.parts
|
||||
.first()
|
||||
.filter(|part| part.typ == Viewtype::Webxdc)
|
||||
.is_some()
|
||||
.is_some_and(|part| part.typ == Viewtype::Webxdc)
|
||||
{
|
||||
can_info_msg = false;
|
||||
Some(
|
||||
@@ -3357,7 +3356,7 @@ async fn apply_chat_name_avatar_and_description_changes(
|
||||
let old_name = &sanitize_single_line(old_name);
|
||||
better_msg.get_or_insert(
|
||||
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
|
||||
stock_str::msg_broadcast_name_changed(context, old_name, grpname).await
|
||||
stock_str::msg_broadcast_name_changed(context, old_name, grpname)
|
||||
} else {
|
||||
stock_str::msg_grp_name(context, old_name, grpname, from_id).await
|
||||
},
|
||||
@@ -3420,7 +3419,7 @@ async fn apply_chat_name_avatar_and_description_changes(
|
||||
// apart from that, the group-avatar is send along with various other messages
|
||||
better_msg.get_or_insert(
|
||||
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
|
||||
stock_str::msg_broadcast_img_changed(context).await
|
||||
stock_str::msg_broadcast_img_changed(context)
|
||||
} else {
|
||||
match avatar_action {
|
||||
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
|
||||
@@ -3860,7 +3859,7 @@ async fn apply_in_broadcast_changes(
|
||||
info!(context, "No-op broadcast 'Member added' message (TRASH)");
|
||||
"".to_string()
|
||||
} else {
|
||||
stock_str::msg_you_joined_broadcast(context).await
|
||||
stock_str::msg_you_joined_broadcast(context)
|
||||
};
|
||||
|
||||
better_msg.get_or_insert(msg);
|
||||
@@ -3876,7 +3875,7 @@ async fn apply_in_broadcast_changes(
|
||||
chat::delete_broadcast_secret(context, chat.id).await?;
|
||||
|
||||
if from_id == ContactId::SELF {
|
||||
better_msg.get_or_insert(stock_str::msg_you_left_broadcast(context).await);
|
||||
better_msg.get_or_insert(stock_str::msg_you_left_broadcast(context));
|
||||
} else {
|
||||
better_msg.get_or_insert(
|
||||
stock_str::msg_del_member_local(context, ContactId::SELF, from_id).await,
|
||||
|
||||
@@ -5108,97 +5108,75 @@ async fn test_dont_verify_by_verified_by_unknown() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that second device assigns outgoing encrypted messages
|
||||
/// to 1:1 chat with key-contact even if the key of the contact is unknown.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recv_outgoing_msg_before_securejoin() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
let a0 = &tcm.elena().await;
|
||||
let a1 = &tcm.elena().await;
|
||||
|
||||
tcm.execute_securejoin(bob, a0).await;
|
||||
let chat_id_a0_bob = a0.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Group);
|
||||
assert!(!chat_a1.is_encrypted(a1).await?);
|
||||
assert_eq!(
|
||||
chat::get_chat_contacts(a1, chat_a1.id).await?,
|
||||
[a1.add_or_lookup_address_contact_id(bob).await]
|
||||
);
|
||||
assert_eq!(
|
||||
chat_a1.why_cant_send(a1).await?,
|
||||
Some(CantSendReason::NotAMember)
|
||||
);
|
||||
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
assert_eq!(msg_a1.chat_id, chat_a1.id);
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(
|
||||
chat_a1.why_cant_send(a1).await?,
|
||||
Some(CantSendReason::NotAMember)
|
||||
);
|
||||
|
||||
let msg_a1 = tcm.send_recv(bob, a1, "Hi back").await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Single);
|
||||
assert!(chat_a1.is_encrypted(a1).await?);
|
||||
// Weird, but fine, anyway the bigger problem is the conversation split into two chats.
|
||||
assert_eq!(
|
||||
chat_a1.why_cant_send(a1).await?,
|
||||
Some(CantSendReason::ContactRequest)
|
||||
);
|
||||
|
||||
let a0 = &tcm.alice().await;
|
||||
let a1 = &tcm.alice().await;
|
||||
|
||||
tcm.execute_securejoin(bob, a0).await;
|
||||
let chat_id_a0_bob = a0.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
|
||||
// Device a1 does not have Bob's key.
|
||||
// Message is still received in an encrypted 1:1 chat with Bob.
|
||||
// a1 learns the fingerprint of Bob from the Intended Recipient Fingerprint packet,
|
||||
// but not the key.
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Single);
|
||||
assert!(chat_a1.is_encrypted(a1).await?);
|
||||
|
||||
// Cannot send because a1 does not have Bob's key.
|
||||
assert!(!chat_a1.can_send(a1).await?);
|
||||
assert_eq!(
|
||||
chat_a1.why_cant_send(a1).await?,
|
||||
Some(CantSendReason::MissingKey)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
chat::get_chat_contacts(a1, chat_a1.id).await?,
|
||||
[a1.add_or_lookup_contact_id(bob).await]
|
||||
[a1.add_or_lookup_contact_id_no_key(bob).await]
|
||||
);
|
||||
assert!(chat_a1.can_send(a1).await?);
|
||||
assert!(!chat_a1.can_send(a1).await?);
|
||||
|
||||
let a1_chat_id = a1.create_chat_id(bob).await;
|
||||
assert_eq!(a1_chat_id, msg_a1.chat_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that outgoing message cannot be assigned to 1:1 chat
|
||||
/// without the intended recipient fingerprint.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_recv_outgoing_msg_before_having_key_and_after() -> Result<()> {
|
||||
async fn test_recv_outgoing_msg_no_intended_recipient_fingerprint() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let a0 = &tcm.elena().await;
|
||||
let a1 = &tcm.elena().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice = &tcm.alice().await;
|
||||
|
||||
tcm.execute_securejoin(bob, a0).await;
|
||||
let chat_id_a0_bob = a0.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
|
||||
assert_eq!(chat_a1.typ, Chattype::Group);
|
||||
assert!(!chat_a1.is_encrypted(a1).await?);
|
||||
let payload = include_bytes!(
|
||||
"../../test-data/message/alice_to_bob_no_intended_recipient_fingerprint.eml"
|
||||
);
|
||||
|
||||
// Alice does not have Bob's key.
|
||||
// Message is encrypted, but is received in ad hoc group with Bob's address.
|
||||
let rcvd_msg = receive_imf(alice, payload, false).await?.unwrap();
|
||||
let msg_alice = Message::load_from_db(alice, rcvd_msg.msg_ids[0]).await?;
|
||||
|
||||
assert!(msg_alice.get_showpadlock());
|
||||
let chat_alice = Chat::load_from_db(alice, msg_alice.chat_id).await?;
|
||||
assert_eq!(chat_alice.typ, Chattype::Group);
|
||||
assert!(!chat_alice.is_encrypted(alice).await?);
|
||||
|
||||
// Cannot send because Bob's key is unknown.
|
||||
assert!(!chat_alice.can_send(alice).await?);
|
||||
assert_eq!(
|
||||
chat_alice.why_cant_send(alice).await?,
|
||||
Some(CantSendReason::NotAMember)
|
||||
);
|
||||
|
||||
// Device a1 somehow learns Bob's key and creates the corresponding chat. However, this doesn't
|
||||
// help because we only look up key contacts by address in a particular chat and the new chat
|
||||
// isn't referenced by the received message. This is fixed by sending and receiving Intended
|
||||
// Recipient Fingerprint subpackets which elena doesn't send.
|
||||
a1.create_chat_id(bob).await;
|
||||
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
|
||||
let msg_a1 = a1.recv_msg(&sent_msg).await;
|
||||
assert!(msg_a1.get_showpadlock());
|
||||
assert_eq!(msg_a1.chat_id, chat_a1.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -355,6 +355,7 @@ async fn inbox_loop(
|
||||
stop_token,
|
||||
} = inbox_handlers;
|
||||
|
||||
let transport_id = connection.transport_id();
|
||||
let ctx1 = ctx.clone();
|
||||
let fut = async move {
|
||||
let ctx = ctx1;
|
||||
@@ -368,23 +369,34 @@ async fn inbox_loop(
|
||||
let session = if let Some(session) = old_session.take() {
|
||||
session
|
||||
} else {
|
||||
info!(ctx, "Preparing new IMAP session for inbox.");
|
||||
info!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Preparing new IMAP session for inbox."
|
||||
);
|
||||
match connection.prepare(&ctx).await {
|
||||
Err(err) => {
|
||||
warn!(ctx, "Failed to prepare inbox connection: {err:#}.");
|
||||
warn!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Failed to prepare inbox connection: {err:#}."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(session) => session,
|
||||
Ok(session) => {
|
||||
info!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Prepared new IMAP session for inbox."
|
||||
);
|
||||
session
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match inbox_fetch_idle(&ctx, &mut connection, session).await {
|
||||
Err(err) => warn!(ctx, "Failed inbox fetch_idle: {err:#}."),
|
||||
Err(err) => warn!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Failed inbox fetch_idle: {err:#}."
|
||||
),
|
||||
Ok(session) => {
|
||||
info!(
|
||||
ctx,
|
||||
"IMAP loop iteration for inbox finished, keeping the session."
|
||||
);
|
||||
old_session = Some(session);
|
||||
}
|
||||
}
|
||||
@@ -394,7 +406,7 @@ async fn inbox_loop(
|
||||
stop_token
|
||||
.cancelled()
|
||||
.map(|_| {
|
||||
info!(ctx, "Shutting down inbox loop.");
|
||||
info!(ctx, "Transport {transport_id}: Shutting down inbox loop.");
|
||||
})
|
||||
.race(fut)
|
||||
.await;
|
||||
@@ -431,17 +443,25 @@ pub async fn convert_folder_meaning(
|
||||
}
|
||||
|
||||
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
|
||||
let transport_id = session.transport_id();
|
||||
|
||||
// Update quota no more than once a minute.
|
||||
if ctx.quota_needs_update(session.transport_id(), 60).await
|
||||
&& let Err(err) = ctx.update_recent_quota(&mut session).await
|
||||
{
|
||||
warn!(ctx, "Failed to update quota: {:#}.", err);
|
||||
warn!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Failed to update quota: {err:#}."
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(()) = imap.resync_request_receiver.try_recv()
|
||||
&& let Err(err) = session.resync_folders(ctx).await
|
||||
{
|
||||
warn!(ctx, "Failed to resync folders: {:#}.", err);
|
||||
warn!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Failed to resync folders: {err:#}."
|
||||
);
|
||||
imap.resync_request_sender.try_send(()).ok();
|
||||
}
|
||||
|
||||
@@ -456,7 +476,10 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(ctx, "Failed to get last housekeeping time: {}", err);
|
||||
warn!(
|
||||
ctx,
|
||||
"Transport {transport_id}: Failed to get last housekeeping time: {err:#}"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -486,6 +509,8 @@ async fn fetch_idle(
|
||||
mut session: Session,
|
||||
folder_meaning: FolderMeaning,
|
||||
) -> Result<Session> {
|
||||
let transport_id = session.transport_id();
|
||||
|
||||
let Some((folder_config, watch_folder)) = convert_folder_meaning(ctx, folder_meaning).await?
|
||||
else {
|
||||
// The folder is not configured.
|
||||
@@ -537,7 +562,7 @@ async fn fetch_idle(
|
||||
if !session.can_idle() {
|
||||
info!(
|
||||
ctx,
|
||||
"IMAP session does not support IDLE, going to fake idle."
|
||||
"Transport {transport_id}: IMAP session does not support IDLE, going to fake idle."
|
||||
);
|
||||
connection.fake_idle(ctx, watch_folder).await?;
|
||||
return Ok(session);
|
||||
@@ -550,15 +575,14 @@ async fn fetch_idle(
|
||||
.log_err(ctx)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
info!(ctx, "IMAP IDLE is disabled, going to fake idle.");
|
||||
info!(
|
||||
ctx,
|
||||
"Transport {transport_id}: IMAP IDLE is disabled, going to fake idle."
|
||||
);
|
||||
connection.fake_idle(ctx, watch_folder).await?;
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
info!(
|
||||
ctx,
|
||||
"IMAP session in folder {watch_folder:?} supports IDLE, using it."
|
||||
);
|
||||
let session = session
|
||||
.idle(
|
||||
ctx,
|
||||
@@ -619,10 +643,6 @@ async fn simple_imap_loop(
|
||||
match fetch_idle(&ctx, &mut connection, session, folder_meaning).await {
|
||||
Err(err) => warn!(ctx, "Failed fetch_idle: {err:#}"),
|
||||
Ok(session) => {
|
||||
info!(
|
||||
ctx,
|
||||
"IMAP loop iteration for {folder_meaning} finished, keeping the session"
|
||||
);
|
||||
old_session = Some(session);
|
||||
}
|
||||
}
|
||||
@@ -876,7 +896,7 @@ impl Scheduler {
|
||||
let timeout_duration = std::time::Duration::from_secs(30);
|
||||
|
||||
let tracker = TaskTracker::new();
|
||||
for b in self.inboxes.into_iter().chain(self.oboxes.into_iter()) {
|
||||
for b in self.inboxes.into_iter().chain(self.oboxes) {
|
||||
let context = context.clone();
|
||||
tracker.spawn(async move {
|
||||
tokio::time::timeout(timeout_duration, b.handle)
|
||||
|
||||
@@ -109,36 +109,36 @@ impl DetailedConnectivity {
|
||||
}
|
||||
}
|
||||
|
||||
async fn to_string_imap(&self, context: &Context) -> String {
|
||||
fn to_string_imap(&self, context: &Context) -> String {
|
||||
match self {
|
||||
DetailedConnectivity::Error(e) => stock_str::error(context, e).await,
|
||||
DetailedConnectivity::Error(e) => stock_str::error(context, e),
|
||||
DetailedConnectivity::Uninitialized => "Not started".to_string(),
|
||||
DetailedConnectivity::Connecting => stock_str::connecting(context).await,
|
||||
DetailedConnectivity::Connecting => stock_str::connecting(context),
|
||||
DetailedConnectivity::Preparing | DetailedConnectivity::Working => {
|
||||
stock_str::updating(context).await
|
||||
stock_str::updating(context)
|
||||
}
|
||||
DetailedConnectivity::InterruptingIdle | DetailedConnectivity::Idle => {
|
||||
stock_str::connected(context).await
|
||||
stock_str::connected(context)
|
||||
}
|
||||
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn to_string_smtp(&self, context: &Context) -> String {
|
||||
fn to_string_smtp(&self, context: &Context) -> String {
|
||||
match self {
|
||||
DetailedConnectivity::Error(e) => stock_str::error(context, e).await,
|
||||
DetailedConnectivity::Error(e) => stock_str::error(context, e),
|
||||
DetailedConnectivity::Uninitialized => {
|
||||
"You did not try to send a message recently.".to_string()
|
||||
}
|
||||
DetailedConnectivity::Connecting => stock_str::connecting(context).await,
|
||||
DetailedConnectivity::Working => stock_str::sending(context).await,
|
||||
DetailedConnectivity::Connecting => stock_str::connecting(context),
|
||||
DetailedConnectivity::Working => stock_str::sending(context),
|
||||
|
||||
// We don't know any more than that the last message was sent successfully;
|
||||
// since sending the last message, connectivity could have changed, which we don't notice
|
||||
// until another message is sent
|
||||
DetailedConnectivity::InterruptingIdle
|
||||
| DetailedConnectivity::Preparing
|
||||
| DetailedConnectivity::Idle => stock_str::last_msg_sent_successfully(context).await,
|
||||
| DetailedConnectivity::Idle => stock_str::last_msg_sent_successfully(context),
|
||||
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -369,8 +369,8 @@ impl Context {
|
||||
.get_config_bool(crate::config::Config::ProxyEnabled)
|
||||
.await?
|
||||
{
|
||||
let proxy_enabled = stock_str::proxy_enabled(self).await;
|
||||
let proxy_description = stock_str::proxy_description(self).await;
|
||||
let proxy_enabled = stock_str::proxy_enabled(self);
|
||||
let proxy_description = stock_str::proxy_description(self);
|
||||
ret += &format!("<h3>{proxy_enabled}</h3><ul><li>{proxy_description}</li></ul>");
|
||||
}
|
||||
|
||||
@@ -396,7 +396,7 @@ impl Context {
|
||||
_ => {
|
||||
ret += &format!(
|
||||
"<h3>{}</h3>\n</body></html>\n",
|
||||
stock_str::not_connected(self).await
|
||||
stock_str::not_connected(self)
|
||||
);
|
||||
return Ok(ret);
|
||||
}
|
||||
@@ -412,7 +412,7 @@ impl Context {
|
||||
// =============================================================================================
|
||||
|
||||
let watched_folders = get_watched_folder_configs(self).await?;
|
||||
let incoming_messages = stock_str::incoming_messages(self).await;
|
||||
let incoming_messages = stock_str::incoming_messages(self);
|
||||
ret += &format!("<h3>{incoming_messages}</h3><ul>");
|
||||
|
||||
let transports = self
|
||||
@@ -449,7 +449,7 @@ impl Context {
|
||||
ret += &*escaper::encode_minimal(&foldername);
|
||||
}
|
||||
ret += ":</b> ";
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self).await);
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self));
|
||||
ret += "<br />";
|
||||
|
||||
folder_added = true;
|
||||
@@ -464,7 +464,7 @@ impl Context {
|
||||
|
||||
ret += &*detailed.to_icon();
|
||||
ret += " ";
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self).await);
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self));
|
||||
ret += "<br />";
|
||||
}
|
||||
}
|
||||
@@ -504,13 +504,12 @@ impl Context {
|
||||
);
|
||||
}
|
||||
|
||||
let messages = stock_str::messages(self).await;
|
||||
let messages = stock_str::messages(self);
|
||||
let part_of_total_used = stock_str::part_of_total_used(
|
||||
self,
|
||||
&resource.usage.to_string(),
|
||||
&resource.limit.to_string(),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
ret += &match &resource.name {
|
||||
Atom(resource_name) => {
|
||||
format!(
|
||||
@@ -531,7 +530,7 @@ impl Context {
|
||||
// - most times, this is the only item anyway
|
||||
let usage = &format_size(resource.usage * 1024, BINARY);
|
||||
let limit = &format_size(resource.limit * 1024, BINARY);
|
||||
stock_str::part_of_total_used(self, usage, limit).await
|
||||
stock_str::part_of_total_used(self, usage, limit)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -565,12 +564,12 @@ impl Context {
|
||||
// Your last message was sent successfully
|
||||
// =============================================================================================
|
||||
|
||||
let outgoing_messages = stock_str::outgoing_messages(self).await;
|
||||
let outgoing_messages = stock_str::outgoing_messages(self);
|
||||
ret += &format!("<h3>{outgoing_messages}</h3><ul><li>");
|
||||
let detailed = smtp.get_detailed();
|
||||
ret += &*detailed.to_icon();
|
||||
ret += " ";
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self).await);
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self));
|
||||
ret += "</li></ul>";
|
||||
|
||||
// =============================================================================================
|
||||
|
||||
@@ -158,7 +158,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
chat::add_info_msg_with_cmd(
|
||||
context,
|
||||
private_chat_id,
|
||||
&stock_str::securejoin_wait(context).await,
|
||||
&stock_str::securejoin_wait(context),
|
||||
SystemMessage::SecurejoinWait,
|
||||
None,
|
||||
time(),
|
||||
|
||||
@@ -109,10 +109,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
let mut i = 0..msg_cnt;
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob));
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
|
||||
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob));
|
||||
|
||||
let contact_alice_id = bob.add_or_lookup_contact_no_key(&alice).await.id;
|
||||
let sent = bob.pop_sent_msg().await;
|
||||
@@ -250,7 +250,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
let chat = alice.get_chat(&bob).await;
|
||||
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
|
||||
assert!(msg.is_info());
|
||||
let expected_text = messages_e2ee_info_msg(&alice).await;
|
||||
let expected_text = messages_e2ee_info_msg(&alice);
|
||||
assert_eq!(msg.get_text(), expected_text);
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
// The `SecurejoinWait` info message has been removed, but the e2ee notice remains.
|
||||
let msg = get_chat_msg(&bob, bob_chat.get_id(), 0, 1).await;
|
||||
assert!(msg.is_info());
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
|
||||
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -625,7 +625,7 @@ async fn test_secure_join_group_ex(v3: bool, remove_invite: bool) -> Result<()>
|
||||
// - You added member bob@example.net
|
||||
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
|
||||
assert!(msg.is_info());
|
||||
let expected_text = messages_e2ee_info_msg(&alice).await;
|
||||
let expected_text = messages_e2ee_info_msg(&alice);
|
||||
assert_eq!(msg.get_text(), expected_text);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,12 +34,10 @@ pub(crate) fn remove_message_footer<'a>(
|
||||
// some providers encode `-- ` to `=2D-` which results in only `--`;
|
||||
// use that only when no other footer is found
|
||||
// and if the line before is empty and the line after is not empty
|
||||
"--" => {
|
||||
if (ix == 0 || lines.get(ix.saturating_sub(1)).is_none_or_empty())
|
||||
&& !lines.get(ix + 1).is_none_or_empty()
|
||||
{
|
||||
nearly_standard_footer = Some(ix);
|
||||
}
|
||||
"--" if (ix == 0 || lines.get(ix.saturating_sub(1)).is_none_or_empty())
|
||||
&& !lines.get(ix + 1).is_none_or_empty() =>
|
||||
{
|
||||
nearly_standard_footer = Some(ix);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
@@ -901,7 +901,7 @@ async fn maybe_add_mvbox_move_deprecation_message(context: &Context) -> Result<(
|
||||
if !context.get_config_bool(Config::OnlyFetchMvbox).await?
|
||||
&& context.get_config_bool(Config::MvboxMove).await?
|
||||
{
|
||||
let mut msg = Message::new_text(stock_str::mvbox_move_deprecation(context).await);
|
||||
let mut msg = Message::new_text(stock_str::mvbox_move_deprecation(context));
|
||||
add_device_msg(context, Some("mvbox_move_deprecation"), Some(&mut msg)).await?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -71,24 +71,6 @@ struct InnerPool {
|
||||
/// This mutex is locked when write connection
|
||||
/// is outside the pool.
|
||||
pub(crate) write_mutex: Arc<Mutex<()>>,
|
||||
|
||||
/// WAL checkpointing mutex.
|
||||
///
|
||||
/// This mutex ensures that no more than one thread
|
||||
/// runs WAL checkpointing at the same time.
|
||||
///
|
||||
/// Normal procedures acquire either one read connection
|
||||
/// or one write connection with a write mutex,
|
||||
/// and return the resources without trying to acquire
|
||||
/// more connections or trying to acquire write mutex
|
||||
/// without returning the read connection first.
|
||||
/// WAL checkpointing is special, it tries to acquire all
|
||||
/// connections and the write mutex,
|
||||
/// so two threads doing this at the same time
|
||||
/// may result in a deadlock with one thread
|
||||
/// waiting for a write lock and the other thread
|
||||
/// waiting for a connection.
|
||||
wal_checkpoint_mutex: Mutex<()>,
|
||||
}
|
||||
|
||||
impl InnerPool {
|
||||
@@ -209,7 +191,6 @@ impl Pool {
|
||||
connections: parking_lot::Mutex::new(connections),
|
||||
semaphore,
|
||||
write_mutex: Default::default(),
|
||||
wal_checkpoint_mutex: Default::default(),
|
||||
});
|
||||
Pool { inner }
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ pub(crate) struct WalCheckpointStats {
|
||||
|
||||
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
|
||||
pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
|
||||
let _guard = pool.inner.wal_checkpoint_mutex.lock().await;
|
||||
let t_start = Time::now();
|
||||
|
||||
// Do as much work as possible without blocking anybody.
|
||||
@@ -48,22 +47,25 @@ pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
|
||||
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
|
||||
})?;
|
||||
|
||||
// Kick out writers.
|
||||
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
|
||||
// Kick out writers. `write_mutex` should be locked before taking an `InnerPool.semaphore`
|
||||
// permit to avoid ABBA deadlocks, so drop `conn` which holds a semaphore permit.
|
||||
drop(conn);
|
||||
let _write_lock = Arc::clone(&pool.inner.write_mutex).lock_owned().await;
|
||||
let t_writers_blocked = Time::now();
|
||||
let conn = pool.get(query_only).await?;
|
||||
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
|
||||
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
|
||||
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
|
||||
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
|
||||
// readers.
|
||||
let mut read_conns = Vec::with_capacity(crate::sql::Sql::N_DB_CONNECTIONS - 1);
|
||||
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
|
||||
let mut read_conns = Vec::with_capacity(Sql::N_DB_CONNECTIONS - 1);
|
||||
for _ in 0..(Sql::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
read_conns.clear();
|
||||
// Checkpoint the remaining WAL pages without blocking readers.
|
||||
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
|
||||
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
|
||||
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
|
||||
let pages_total: i64 = row.get(1)?;
|
||||
let pages_checkpointed: i64 = row.get(2)?;
|
||||
@@ -71,7 +73,7 @@ pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
|
||||
})
|
||||
})?;
|
||||
// Kick out readers to avoid blocking/SQLITE_BUSY.
|
||||
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
|
||||
for _ in 0..(Sql::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
let t_readers_blocked = Time::now();
|
||||
|
||||
@@ -281,7 +281,7 @@ async fn send_stats(context: &Context) -> Result<ChatId> {
|
||||
let chat_id = get_stats_chat_id(context).await?;
|
||||
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
msg.set_text(crate::stock_str::stats_msg_body(context).await);
|
||||
msg.set_text(crate::stock_str::stats_msg_body(context));
|
||||
|
||||
let stats = get_stats(context).await?;
|
||||
|
||||
@@ -554,6 +554,7 @@ async fn get_message_stats(context: &Context) -> Result<BTreeMap<Chattype, Messa
|
||||
}
|
||||
|
||||
pub(crate) async fn update_message_stats(context: &Context) -> Result<()> {
|
||||
info!(context, "Updating message statistics.");
|
||||
for chattype in [Chattype::Single, Chattype::Group, Chattype::OutBroadcast] {
|
||||
update_message_stats_inner(context, chattype).await?;
|
||||
}
|
||||
|
||||
414
src/stock_str.rs
414
src/stock_str.rs
@@ -4,9 +4,9 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use parking_lot::RwLock;
|
||||
use strum::EnumProperty as EnumPropertyTrait;
|
||||
use strum_macros::EnumProperty;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::accounts::Accounts;
|
||||
use crate::blob::BlobObject;
|
||||
@@ -463,17 +463,16 @@ impl StockStrings {
|
||||
}
|
||||
}
|
||||
|
||||
async fn translated(&self, id: StockMessage) -> String {
|
||||
fn translated(&self, id: StockMessage) -> String {
|
||||
self.translated_stockstrings
|
||||
.read()
|
||||
.await
|
||||
.get(&(id as usize))
|
||||
.map(AsRef::as_ref)
|
||||
.unwrap_or_else(|| id.fallback())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
|
||||
fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
|
||||
if stockstring.contains("%1") && !id.fallback().contains("%1") {
|
||||
bail!(
|
||||
"translation {} contains invalid %1 placeholder, default is {}",
|
||||
@@ -490,14 +489,13 @@ impl StockStrings {
|
||||
}
|
||||
self.translated_stockstrings
|
||||
.write()
|
||||
.await
|
||||
.insert(id as usize, stockstring);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn translated(context: &Context, id: StockMessage) -> String {
|
||||
context.translated_stockstrings.translated(id).await
|
||||
fn translated(context: &Context, id: StockMessage) -> String {
|
||||
context.translated_stockstrings.translated(id)
|
||||
}
|
||||
|
||||
/// Helper trait only meant to be implemented for [`String`].
|
||||
@@ -546,43 +544,43 @@ impl ContactId {
|
||||
impl StockStringMods for String {}
|
||||
|
||||
/// Stock string: `No messages.`.
|
||||
pub(crate) async fn no_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::NoMessages).await
|
||||
pub(crate) fn no_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::NoMessages)
|
||||
}
|
||||
|
||||
/// Stock string: `Me`.
|
||||
pub(crate) async fn self_msg(context: &Context) -> String {
|
||||
translated(context, StockMessage::SelfMsg).await
|
||||
pub(crate) fn self_msg(context: &Context) -> String {
|
||||
translated(context, StockMessage::SelfMsg)
|
||||
}
|
||||
|
||||
/// Stock string: `Draft`.
|
||||
pub(crate) async fn draft(context: &Context) -> String {
|
||||
translated(context, StockMessage::Draft).await
|
||||
pub(crate) fn draft(context: &Context) -> String {
|
||||
translated(context, StockMessage::Draft)
|
||||
}
|
||||
|
||||
/// Stock string: `Voice message`.
|
||||
pub(crate) async fn voice_message(context: &Context) -> String {
|
||||
translated(context, StockMessage::VoiceMessage).await
|
||||
pub(crate) fn voice_message(context: &Context) -> String {
|
||||
translated(context, StockMessage::VoiceMessage)
|
||||
}
|
||||
|
||||
/// Stock string: `Image`.
|
||||
pub(crate) async fn image(context: &Context) -> String {
|
||||
translated(context, StockMessage::Image).await
|
||||
pub(crate) fn image(context: &Context) -> String {
|
||||
translated(context, StockMessage::Image)
|
||||
}
|
||||
|
||||
/// Stock string: `Video`.
|
||||
pub(crate) async fn video(context: &Context) -> String {
|
||||
translated(context, StockMessage::Video).await
|
||||
pub(crate) fn video(context: &Context) -> String {
|
||||
translated(context, StockMessage::Video)
|
||||
}
|
||||
|
||||
/// Stock string: `Audio`.
|
||||
pub(crate) async fn audio(context: &Context) -> String {
|
||||
translated(context, StockMessage::Audio).await
|
||||
pub(crate) fn audio(context: &Context) -> String {
|
||||
translated(context, StockMessage::Audio)
|
||||
}
|
||||
|
||||
/// Stock string: `File`.
|
||||
pub(crate) async fn file(context: &Context) -> String {
|
||||
translated(context, StockMessage::File).await
|
||||
pub(crate) fn file(context: &Context) -> String {
|
||||
translated(context, StockMessage::File)
|
||||
}
|
||||
|
||||
/// Stock string: `Group name changed from "%1$s" to "%2$s".`.
|
||||
@@ -594,12 +592,10 @@ pub(crate) async fn msg_grp_name(
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouChangedGrpName)
|
||||
.await
|
||||
.replace1(from_group)
|
||||
.replace2(to_group)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgGrpNameChangedBy)
|
||||
.await
|
||||
.replace1(from_group)
|
||||
.replace2(to_group)
|
||||
.replace3(&by_contact.get_stock_name(context).await)
|
||||
@@ -608,10 +604,9 @@ pub(crate) async fn msg_grp_name(
|
||||
|
||||
pub(crate) async fn msg_grp_img_changed(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouChangedGrpImg).await
|
||||
translated(context, StockMessage::MsgYouChangedGrpImg)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgGrpImgChangedBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
@@ -621,10 +616,9 @@ pub(crate) async fn msg_chat_description_changed(
|
||||
by_contact: ContactId,
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouChangedDescription).await
|
||||
translated(context, StockMessage::MsgYouChangedDescription)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgChatDescriptionChangedBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
@@ -640,16 +634,11 @@ pub(crate) async fn msg_add_member_local(
|
||||
) -> String {
|
||||
let whom = added_member.get_stock_name(context).await;
|
||||
if by_contact == ContactId::UNDEFINED {
|
||||
translated(context, StockMessage::MsgAddMember)
|
||||
.await
|
||||
.replace1(&whom)
|
||||
translated(context, StockMessage::MsgAddMember).replace1(&whom)
|
||||
} else if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouAddMember)
|
||||
.await
|
||||
.replace1(&whom)
|
||||
translated(context, StockMessage::MsgYouAddMember).replace1(&whom)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgAddMemberBy)
|
||||
.await
|
||||
.replace1(&whom)
|
||||
.replace2(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
@@ -666,16 +655,11 @@ pub(crate) async fn msg_del_member_local(
|
||||
) -> String {
|
||||
let whom = removed_member.get_stock_name(context).await;
|
||||
if by_contact == ContactId::UNDEFINED {
|
||||
translated(context, StockMessage::MsgDelMember)
|
||||
.await
|
||||
.replace1(&whom)
|
||||
translated(context, StockMessage::MsgDelMember).replace1(&whom)
|
||||
} else if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouDelMember)
|
||||
.await
|
||||
.replace1(&whom)
|
||||
translated(context, StockMessage::MsgYouDelMember).replace1(&whom)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgDelMemberBy)
|
||||
.await
|
||||
.replace1(&whom)
|
||||
.replace2(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
@@ -684,22 +668,21 @@ pub(crate) async fn msg_del_member_local(
|
||||
/// Stock string: `You left the group.` or `Group left by %1$s.`.
|
||||
pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouLeftGroup).await
|
||||
translated(context, StockMessage::MsgYouLeftGroup)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgGroupLeftBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `You left the channel.`
|
||||
pub(crate) async fn msg_you_left_broadcast(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgYouLeftBroadcast).await
|
||||
pub(crate) fn msg_you_left_broadcast(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgYouLeftBroadcast)
|
||||
}
|
||||
|
||||
/// Stock string: `You joined the channel.`
|
||||
pub(crate) async fn msg_you_joined_broadcast(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgYouJoinedBroadcast).await
|
||||
pub(crate) fn msg_you_joined_broadcast(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgYouJoinedBroadcast)
|
||||
}
|
||||
|
||||
/// Stock string: `%1$s invited you to join this channel. Waiting for the device of %2$s to reply…`.
|
||||
@@ -709,7 +692,6 @@ pub(crate) async fn secure_join_broadcast_started(
|
||||
) -> String {
|
||||
if let Ok(contact) = Contact::get_by_id(context, inviter_contact_id).await {
|
||||
translated(context, StockMessage::SecureJoinBroadcastStarted)
|
||||
.await
|
||||
.replace1(contact.get_display_name())
|
||||
.replace2(contact.get_display_name())
|
||||
} else {
|
||||
@@ -718,16 +700,15 @@ pub(crate) async fn secure_join_broadcast_started(
|
||||
}
|
||||
|
||||
/// Stock string: `Channel name changed from "1%s" to "2$s".`
|
||||
pub(crate) async fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
|
||||
pub(crate) fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
|
||||
translated(context, StockMessage::MsgBroadcastNameChanged)
|
||||
.await
|
||||
.replace1(from)
|
||||
.replace2(to)
|
||||
}
|
||||
|
||||
/// Stock string `Channel image changed.`
|
||||
pub(crate) async fn msg_broadcast_img_changed(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgBroadcastImgChanged).await
|
||||
pub(crate) fn msg_broadcast_img_changed(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgBroadcastImgChanged)
|
||||
}
|
||||
|
||||
/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`.
|
||||
@@ -739,12 +720,10 @@ pub(crate) async fn msg_reacted(
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouReacted)
|
||||
.await
|
||||
.replace1(reaction)
|
||||
.replace2(summary)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgReactedBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
.replace2(reaction)
|
||||
.replace3(summary)
|
||||
@@ -752,27 +731,26 @@ pub(crate) async fn msg_reacted(
|
||||
}
|
||||
|
||||
/// Stock string: `GIF`.
|
||||
pub(crate) async fn gif(context: &Context) -> String {
|
||||
translated(context, StockMessage::Gif).await
|
||||
pub(crate) fn gif(context: &Context) -> String {
|
||||
translated(context, StockMessage::Gif)
|
||||
}
|
||||
|
||||
/// Stock string: `No encryption.`.
|
||||
pub(crate) async fn encr_none(context: &Context) -> String {
|
||||
translated(context, StockMessage::EncrNone).await
|
||||
pub(crate) fn encr_none(context: &Context) -> String {
|
||||
translated(context, StockMessage::EncrNone)
|
||||
}
|
||||
|
||||
/// Stock string: `Fingerprints`.
|
||||
pub(crate) async fn finger_prints(context: &Context) -> String {
|
||||
translated(context, StockMessage::FingerPrints).await
|
||||
pub(crate) fn finger_prints(context: &Context) -> String {
|
||||
translated(context, StockMessage::FingerPrints)
|
||||
}
|
||||
|
||||
/// Stock string: `Group image deleted.`.
|
||||
pub(crate) async fn msg_grp_img_deleted(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouDeletedGrpImg).await
|
||||
translated(context, StockMessage::MsgYouDeletedGrpImg)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgGrpImgDeletedBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
@@ -784,7 +762,6 @@ pub(crate) async fn secure_join_started(
|
||||
) -> String {
|
||||
if let Ok(contact) = Contact::get_by_id(context, inviter_contact_id).await {
|
||||
translated(context, StockMessage::SecureJoinStarted)
|
||||
.await
|
||||
.replace1(contact.get_display_name())
|
||||
.replace2(contact.get_display_name())
|
||||
} else {
|
||||
@@ -795,22 +772,21 @@ pub(crate) async fn secure_join_started(
|
||||
/// Stock string: `%1$s replied, waiting for being added to the group…`.
|
||||
pub(crate) async fn secure_join_replies(context: &Context, contact_id: ContactId) -> String {
|
||||
translated(context, StockMessage::SecureJoinReplies)
|
||||
.await
|
||||
.replace1(&contact_id.get_stock_name(context).await)
|
||||
}
|
||||
|
||||
/// Stock string: `Establishing connection, please wait…`.
|
||||
pub(crate) async fn securejoin_wait(context: &Context) -> String {
|
||||
translated(context, StockMessage::SecurejoinWait).await
|
||||
pub(crate) fn securejoin_wait(context: &Context) -> String {
|
||||
translated(context, StockMessage::SecurejoinWait)
|
||||
}
|
||||
|
||||
/// Stock string: `❤️ Seems you're enjoying Delta Chat!`…
|
||||
pub(crate) async fn donation_request(context: &Context) -> String {
|
||||
translated(context, StockMessage::DonationRequest).await
|
||||
pub(crate) fn donation_request(context: &Context) -> String {
|
||||
translated(context, StockMessage::DonationRequest)
|
||||
}
|
||||
|
||||
/// Stock string: `Outgoing video call` or `Outgoing audio call`.
|
||||
pub(crate) async fn outgoing_call(context: &Context, has_video: bool) -> String {
|
||||
pub(crate) fn outgoing_call(context: &Context, has_video: bool) -> String {
|
||||
translated(
|
||||
context,
|
||||
if has_video {
|
||||
@@ -819,11 +795,10 @@ pub(crate) async fn outgoing_call(context: &Context, has_video: bool) -> String
|
||||
StockMessage::OutgoingAudioCall
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stock string: `Incoming video call` or `Incoming audio call`.
|
||||
pub(crate) async fn incoming_call(context: &Context, has_video: bool) -> String {
|
||||
pub(crate) fn incoming_call(context: &Context, has_video: bool) -> String {
|
||||
translated(
|
||||
context,
|
||||
if has_video {
|
||||
@@ -832,26 +807,25 @@ pub(crate) async fn incoming_call(context: &Context, has_video: bool) -> String
|
||||
StockMessage::IncomingAudioCall
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stock string: `Declined call`.
|
||||
pub(crate) async fn declined_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::DeclinedCall).await
|
||||
pub(crate) fn declined_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::DeclinedCall)
|
||||
}
|
||||
|
||||
/// Stock string: `Canceled call`.
|
||||
pub(crate) async fn canceled_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::CanceledCall).await
|
||||
pub(crate) fn canceled_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::CanceledCall)
|
||||
}
|
||||
|
||||
/// Stock string: `Missed call`.
|
||||
pub(crate) async fn missed_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::MissedCall).await
|
||||
pub(crate) fn missed_call(context: &Context) -> String {
|
||||
translated(context, StockMessage::MissedCall)
|
||||
}
|
||||
|
||||
/// Stock string: `Scan to chat with %1$s`.
|
||||
pub(crate) async fn setup_contact_qr_description(
|
||||
pub(crate) fn setup_contact_qr_description(
|
||||
context: &Context,
|
||||
display_name: &str,
|
||||
addr: &str,
|
||||
@@ -861,113 +835,100 @@ pub(crate) async fn setup_contact_qr_description(
|
||||
} else {
|
||||
display_name.to_owned()
|
||||
};
|
||||
translated(context, StockMessage::SetupContactQRDescription)
|
||||
.await
|
||||
.replace1(&name)
|
||||
translated(context, StockMessage::SetupContactQRDescription).replace1(&name)
|
||||
}
|
||||
|
||||
/// Stock string: `Scan to join group %1$s`.
|
||||
pub(crate) async fn secure_join_group_qr_description(context: &Context, chat: &Chat) -> String {
|
||||
translated(context, StockMessage::SecureJoinGroupQRDescription)
|
||||
.await
|
||||
.replace1(chat.get_name())
|
||||
pub(crate) fn secure_join_group_qr_description(context: &Context, chat: &Chat) -> String {
|
||||
translated(context, StockMessage::SecureJoinGroupQRDescription).replace1(chat.get_name())
|
||||
}
|
||||
|
||||
/// Stock string: `Scan to join channel %1$s`.
|
||||
pub(crate) async fn secure_join_broadcast_qr_description(context: &Context, chat: &Chat) -> String {
|
||||
translated(context, StockMessage::SecureJoinBrodcastQRDescription)
|
||||
.await
|
||||
.replace1(chat.get_name())
|
||||
pub(crate) fn secure_join_broadcast_qr_description(context: &Context, chat: &Chat) -> String {
|
||||
translated(context, StockMessage::SecureJoinBrodcastQRDescription).replace1(chat.get_name())
|
||||
}
|
||||
|
||||
/// Stock string: `%1$s verified.`.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn contact_verified(context: &Context, contact: &Contact) -> String {
|
||||
pub(crate) fn contact_verified(context: &Context, contact: &Contact) -> String {
|
||||
let addr = contact.get_display_name();
|
||||
translated(context, StockMessage::ContactVerified)
|
||||
.await
|
||||
.replace1(addr)
|
||||
translated(context, StockMessage::ContactVerified).replace1(addr)
|
||||
}
|
||||
|
||||
/// Stock string: `Archived chats`.
|
||||
pub(crate) async fn archived_chats(context: &Context) -> String {
|
||||
translated(context, StockMessage::ArchivedChats).await
|
||||
pub(crate) fn archived_chats(context: &Context) -> String {
|
||||
translated(context, StockMessage::ArchivedChats)
|
||||
}
|
||||
|
||||
/// Stock string: `Multi Device Synchronization`.
|
||||
pub(crate) async fn sync_msg_subject(context: &Context) -> String {
|
||||
translated(context, StockMessage::SyncMsgSubject).await
|
||||
pub(crate) fn sync_msg_subject(context: &Context) -> String {
|
||||
translated(context, StockMessage::SyncMsgSubject)
|
||||
}
|
||||
|
||||
/// Stock string: `This message is used to synchronize data between your devices.`.
|
||||
pub(crate) async fn sync_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::SyncMsgBody).await
|
||||
pub(crate) fn sync_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::SyncMsgBody)
|
||||
}
|
||||
|
||||
/// Stock string: `Cannot login as \"%1$s\". Please check...`.
|
||||
pub(crate) async fn cannot_login(context: &Context, user: &str) -> String {
|
||||
translated(context, StockMessage::CannotLogin)
|
||||
.await
|
||||
.replace1(user)
|
||||
pub(crate) fn cannot_login(context: &Context, user: &str) -> String {
|
||||
translated(context, StockMessage::CannotLogin).replace1(user)
|
||||
}
|
||||
|
||||
/// Stock string: `Location streaming enabled.`.
|
||||
pub(crate) async fn msg_location_enabled(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgLocationEnabled).await
|
||||
pub(crate) fn msg_location_enabled(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgLocationEnabled)
|
||||
}
|
||||
|
||||
/// Stock string: `Location streaming enabled by ...`.
|
||||
pub(crate) async fn msg_location_enabled_by(context: &Context, contact: ContactId) -> String {
|
||||
if contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEnabledLocation).await
|
||||
translated(context, StockMessage::MsgYouEnabledLocation)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgLocationEnabledBy)
|
||||
.await
|
||||
.replace1(&contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `Location streaming disabled.`.
|
||||
pub(crate) async fn msg_location_disabled(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgLocationDisabled).await
|
||||
pub(crate) fn msg_location_disabled(context: &Context) -> String {
|
||||
translated(context, StockMessage::MsgLocationDisabled)
|
||||
}
|
||||
|
||||
/// Stock string: `Location`.
|
||||
pub(crate) async fn location(context: &Context) -> String {
|
||||
translated(context, StockMessage::Location).await
|
||||
pub(crate) fn location(context: &Context) -> String {
|
||||
translated(context, StockMessage::Location)
|
||||
}
|
||||
|
||||
/// Stock string: `Sticker`.
|
||||
pub(crate) async fn sticker(context: &Context) -> String {
|
||||
translated(context, StockMessage::Sticker).await
|
||||
pub(crate) fn sticker(context: &Context) -> String {
|
||||
translated(context, StockMessage::Sticker)
|
||||
}
|
||||
|
||||
/// Stock string: `Device messages`.
|
||||
pub(crate) async fn device_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::DeviceMessages).await
|
||||
pub(crate) fn device_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::DeviceMessages)
|
||||
}
|
||||
|
||||
/// Stock string: `Saved messages`.
|
||||
pub(crate) async fn saved_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::SavedMessages).await
|
||||
pub(crate) fn saved_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::SavedMessages)
|
||||
}
|
||||
|
||||
/// Stock string: `Messages in this chat are generated locally by...`.
|
||||
pub(crate) async fn device_messages_hint(context: &Context) -> String {
|
||||
translated(context, StockMessage::DeviceMessagesHint).await
|
||||
pub(crate) fn device_messages_hint(context: &Context) -> String {
|
||||
translated(context, StockMessage::DeviceMessagesHint)
|
||||
}
|
||||
|
||||
/// Stock string: `Welcome to Delta Chat! – ...`.
|
||||
pub(crate) async fn welcome_message(context: &Context) -> String {
|
||||
translated(context, StockMessage::WelcomeMessage).await
|
||||
pub(crate) fn welcome_message(context: &Context) -> String {
|
||||
translated(context, StockMessage::WelcomeMessage)
|
||||
}
|
||||
|
||||
/// Stock string: `Message from %1$s`.
|
||||
// TODO: This can compute `self_name` itself instead of asking the caller to do this.
|
||||
pub(crate) async fn subject_for_new_contact(context: &Context, self_name: &str) -> String {
|
||||
translated(context, StockMessage::SubjectForNewContact)
|
||||
.await
|
||||
.replace1(self_name)
|
||||
pub(crate) fn subject_for_new_contact(context: &Context, self_name: &str) -> String {
|
||||
translated(context, StockMessage::SubjectForNewContact).replace1(self_name)
|
||||
}
|
||||
|
||||
/// Stock string: `Message deletion timer is disabled.`.
|
||||
@@ -976,10 +937,9 @@ pub(crate) async fn msg_ephemeral_timer_disabled(
|
||||
by_contact: ContactId,
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouDisabledEphemeralTimer).await
|
||||
translated(context, StockMessage::MsgYouDisabledEphemeralTimer)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerDisabledBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
@@ -991,12 +951,9 @@ pub(crate) async fn msg_ephemeral_timer_enabled(
|
||||
by_contact: ContactId,
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEnabledEphemeralTimer)
|
||||
.await
|
||||
.replace1(timer)
|
||||
translated(context, StockMessage::MsgYouEnabledEphemeralTimer).replace1(timer)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerEnabledBy)
|
||||
.await
|
||||
.replace1(timer)
|
||||
.replace2(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
@@ -1005,10 +962,9 @@ pub(crate) async fn msg_ephemeral_timer_enabled(
|
||||
/// Stock string: `Message deletion timer is set to 1 hour.`.
|
||||
pub(crate) async fn msg_ephemeral_timer_hour(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerHour).await
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerHour)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerHourBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
@@ -1016,10 +972,9 @@ pub(crate) async fn msg_ephemeral_timer_hour(context: &Context, by_contact: Cont
|
||||
/// Stock string: `Message deletion timer is set to 1 day.`.
|
||||
pub(crate) async fn msg_ephemeral_timer_day(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerDay).await
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerDay)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerDayBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
@@ -1027,10 +982,9 @@ pub(crate) async fn msg_ephemeral_timer_day(context: &Context, by_contact: Conta
|
||||
/// Stock string: `Message deletion timer is set to 1 week.`.
|
||||
pub(crate) async fn msg_ephemeral_timer_week(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerWeek).await
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerWeek)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerWeekBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
@@ -1038,57 +992,52 @@ pub(crate) async fn msg_ephemeral_timer_week(context: &Context, by_contact: Cont
|
||||
/// Stock string: `Message deletion timer is set to 1 year.`.
|
||||
pub(crate) async fn msg_ephemeral_timer_year(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerYear).await
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerYear)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerYearBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `Error:\n\n“%1$s”`.
|
||||
pub(crate) async fn configuration_failed(context: &Context, details: &str) -> String {
|
||||
translated(context, StockMessage::ConfigurationFailed)
|
||||
.await
|
||||
.replace1(details)
|
||||
pub(crate) fn configuration_failed(context: &Context, details: &str) -> String {
|
||||
translated(context, StockMessage::ConfigurationFailed).replace1(details)
|
||||
}
|
||||
|
||||
/// Stock string: `⚠️ Date or time of your device seem to be inaccurate (%1$s)...`.
|
||||
// TODO: This could compute now itself.
|
||||
pub(crate) async fn bad_time_msg_body(context: &Context, now: &str) -> String {
|
||||
translated(context, StockMessage::BadTimeMsgBody)
|
||||
.await
|
||||
.replace1(now)
|
||||
pub(crate) fn bad_time_msg_body(context: &Context, now: &str) -> String {
|
||||
translated(context, StockMessage::BadTimeMsgBody).replace1(now)
|
||||
}
|
||||
|
||||
/// Stock string: `⚠️ Your Delta Chat version might be outdated...`.
|
||||
pub(crate) async fn update_reminder_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::UpdateReminderMsgBody).await
|
||||
pub(crate) fn update_reminder_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::UpdateReminderMsgBody)
|
||||
}
|
||||
|
||||
/// Stock string: `Could not find your mail server...`.
|
||||
pub(crate) async fn error_no_network(context: &Context) -> String {
|
||||
translated(context, StockMessage::ErrorNoNetwork).await
|
||||
pub(crate) fn error_no_network(context: &Context) -> String {
|
||||
translated(context, StockMessage::ErrorNoNetwork)
|
||||
}
|
||||
|
||||
/// Stock string: `Messages are end-to-end encrypted.`, used in info-messages, UI may add smth. as `Tap to learn more.`
|
||||
pub(crate) async fn messages_e2ee_info_msg(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatProtectionEnabled).await
|
||||
pub(crate) fn messages_e2ee_info_msg(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatProtectionEnabled)
|
||||
}
|
||||
|
||||
/// Stock string: `Messages are end-to-end encrypted.`
|
||||
pub(crate) async fn messages_are_e2ee(context: &Context) -> String {
|
||||
translated(context, StockMessage::MessagesAreE2ee).await
|
||||
pub(crate) fn messages_are_e2ee(context: &Context) -> String {
|
||||
translated(context, StockMessage::MessagesAreE2ee)
|
||||
}
|
||||
|
||||
/// Stock string: `Reply`.
|
||||
pub(crate) async fn reply_noun(context: &Context) -> String {
|
||||
translated(context, StockMessage::ReplyNoun).await
|
||||
pub(crate) fn reply_noun(context: &Context) -> String {
|
||||
translated(context, StockMessage::ReplyNoun)
|
||||
}
|
||||
|
||||
/// Stock string: `You deleted the \"Saved messages\" chat...`.
|
||||
pub(crate) async fn self_deleted_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::SelfDeletedMsgBody).await
|
||||
pub(crate) fn self_deleted_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::SelfDeletedMsgBody)
|
||||
}
|
||||
|
||||
/// Stock string: `Message deletion timer is set to %1$s minutes.`.
|
||||
@@ -1098,12 +1047,9 @@ pub(crate) async fn msg_ephemeral_timer_minutes(
|
||||
by_contact: ContactId,
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerMinutes)
|
||||
.await
|
||||
.replace1(minutes)
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerMinutes).replace1(minutes)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerMinutesBy)
|
||||
.await
|
||||
.replace1(minutes)
|
||||
.replace2(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
@@ -1116,12 +1062,9 @@ pub(crate) async fn msg_ephemeral_timer_hours(
|
||||
by_contact: ContactId,
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerHours)
|
||||
.await
|
||||
.replace1(hours)
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerHours).replace1(hours)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerHoursBy)
|
||||
.await
|
||||
.replace1(hours)
|
||||
.replace2(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
@@ -1134,12 +1077,9 @@ pub(crate) async fn msg_ephemeral_timer_days(
|
||||
by_contact: ContactId,
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerDays)
|
||||
.await
|
||||
.replace1(days)
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerDays).replace1(days)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerDaysBy)
|
||||
.await
|
||||
.replace1(days)
|
||||
.replace2(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
@@ -1152,112 +1092,103 @@ pub(crate) async fn msg_ephemeral_timer_weeks(
|
||||
by_contact: ContactId,
|
||||
) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerWeeks)
|
||||
.await
|
||||
.replace1(weeks)
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerWeeks).replace1(weeks)
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerWeeksBy)
|
||||
.await
|
||||
.replace1(weeks)
|
||||
.replace2(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `Forwarded`.
|
||||
pub(crate) async fn forwarded(context: &Context) -> String {
|
||||
translated(context, StockMessage::Forwarded).await
|
||||
pub(crate) fn forwarded(context: &Context) -> String {
|
||||
translated(context, StockMessage::Forwarded)
|
||||
}
|
||||
|
||||
/// Stock string: `⚠️ Your provider's storage is about to exceed...`.
|
||||
pub(crate) async fn quota_exceeding(context: &Context, highest_usage: u64) -> String {
|
||||
pub(crate) fn quota_exceeding(context: &Context, highest_usage: u64) -> String {
|
||||
translated(context, StockMessage::QuotaExceedingMsgBody)
|
||||
.await
|
||||
.replace1(&format!("{highest_usage}"))
|
||||
.replace("%%", "%")
|
||||
}
|
||||
|
||||
/// Stock string: `Incoming Messages`.
|
||||
pub(crate) async fn incoming_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::IncomingMessages).await
|
||||
pub(crate) fn incoming_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::IncomingMessages)
|
||||
}
|
||||
|
||||
/// Stock string: `Outgoing Messages`.
|
||||
pub(crate) async fn outgoing_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::OutgoingMessages).await
|
||||
pub(crate) fn outgoing_messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::OutgoingMessages)
|
||||
}
|
||||
|
||||
/// Stock string: `Not connected`.
|
||||
pub(crate) async fn not_connected(context: &Context) -> String {
|
||||
translated(context, StockMessage::NotConnected).await
|
||||
pub(crate) fn not_connected(context: &Context) -> String {
|
||||
translated(context, StockMessage::NotConnected)
|
||||
}
|
||||
|
||||
/// Stock string: `Connected`.
|
||||
pub(crate) async fn connected(context: &Context) -> String {
|
||||
translated(context, StockMessage::Connected).await
|
||||
pub(crate) fn connected(context: &Context) -> String {
|
||||
translated(context, StockMessage::Connected)
|
||||
}
|
||||
|
||||
/// Stock string: `Connecting…`.
|
||||
pub(crate) async fn connecting(context: &Context) -> String {
|
||||
translated(context, StockMessage::Connecting).await
|
||||
pub(crate) fn connecting(context: &Context) -> String {
|
||||
translated(context, StockMessage::Connecting)
|
||||
}
|
||||
|
||||
/// Stock string: `Updating…`.
|
||||
pub(crate) async fn updating(context: &Context) -> String {
|
||||
translated(context, StockMessage::Updating).await
|
||||
pub(crate) fn updating(context: &Context) -> String {
|
||||
translated(context, StockMessage::Updating)
|
||||
}
|
||||
|
||||
/// Stock string: `Sending…`.
|
||||
pub(crate) async fn sending(context: &Context) -> String {
|
||||
translated(context, StockMessage::Sending).await
|
||||
pub(crate) fn sending(context: &Context) -> String {
|
||||
translated(context, StockMessage::Sending)
|
||||
}
|
||||
|
||||
/// Stock string: `Your last message was sent successfully.`.
|
||||
pub(crate) async fn last_msg_sent_successfully(context: &Context) -> String {
|
||||
translated(context, StockMessage::LastMsgSentSuccessfully).await
|
||||
pub(crate) fn last_msg_sent_successfully(context: &Context) -> String {
|
||||
translated(context, StockMessage::LastMsgSentSuccessfully)
|
||||
}
|
||||
|
||||
/// Stock string: `Error: %1$s…`.
|
||||
/// `%1$s` will be replaced by a possibly more detailed, typically english, error description.
|
||||
pub(crate) async fn error(context: &Context, error: &str) -> String {
|
||||
translated(context, StockMessage::Error)
|
||||
.await
|
||||
.replace1(error)
|
||||
pub(crate) fn error(context: &Context, error: &str) -> String {
|
||||
translated(context, StockMessage::Error).replace1(error)
|
||||
}
|
||||
|
||||
/// Stock string: `Not supported by your provider.`.
|
||||
pub(crate) async fn not_supported_by_provider(context: &Context) -> String {
|
||||
translated(context, StockMessage::NotSupportedByProvider).await
|
||||
pub(crate) fn not_supported_by_provider(context: &Context) -> String {
|
||||
translated(context, StockMessage::NotSupportedByProvider)
|
||||
}
|
||||
|
||||
/// Stock string: `Messages`.
|
||||
/// Used as a subtitle in quota context; can be plural always.
|
||||
pub(crate) async fn messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::Messages).await
|
||||
pub(crate) fn messages(context: &Context) -> String {
|
||||
translated(context, StockMessage::Messages)
|
||||
}
|
||||
|
||||
/// Stock string: `%1$s of %2$s used`.
|
||||
pub(crate) async fn part_of_total_used(context: &Context, part: &str, total: &str) -> String {
|
||||
pub(crate) fn part_of_total_used(context: &Context, part: &str, total: &str) -> String {
|
||||
translated(context, StockMessage::PartOfTotallUsed)
|
||||
.await
|
||||
.replace1(part)
|
||||
.replace2(total)
|
||||
}
|
||||
|
||||
/// Stock string: `⚠️ Your email provider %1$s requires end-to-end encryption which is not setup yet. Tap to learn more.`.
|
||||
pub(crate) async fn unencrypted_email(context: &Context, provider: &str) -> String {
|
||||
translated(context, StockMessage::InvalidUnencryptedMail)
|
||||
.await
|
||||
.replace1(provider)
|
||||
translated(context, StockMessage::InvalidUnencryptedMail).replace1(provider)
|
||||
}
|
||||
|
||||
/// Stock string: `The attachment contains anonymous usage statistics, which helps us improve Delta Chat. Thank you!`
|
||||
pub(crate) async fn stats_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::StatsMsgBody).await
|
||||
pub(crate) fn stats_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::StatsMsgBody)
|
||||
}
|
||||
|
||||
/// Stock string: `Others will only see this group after you sent a first message.`.
|
||||
pub(crate) async fn new_group_send_first_message(context: &Context) -> String {
|
||||
translated(context, StockMessage::NewGroupSendFirstMessage).await
|
||||
pub(crate) fn new_group_send_first_message(context: &Context) -> String {
|
||||
translated(context, StockMessage::NewGroupSendFirstMessage)
|
||||
}
|
||||
|
||||
/// Text to put in the [`Qr::Backup2`] rendered SVG image.
|
||||
@@ -1272,46 +1203,44 @@ pub(crate) async fn backup_transfer_qr(context: &Context) -> Result<String> {
|
||||
} else {
|
||||
context.get_primary_self_addr().await?
|
||||
};
|
||||
Ok(translated(context, StockMessage::BackupTransferQr)
|
||||
.await
|
||||
.replace1(&name))
|
||||
Ok(translated(context, StockMessage::BackupTransferQr).replace1(&name))
|
||||
}
|
||||
|
||||
pub(crate) async fn backup_transfer_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::BackupTransferMsgBody).await
|
||||
pub(crate) fn backup_transfer_msg_body(context: &Context) -> String {
|
||||
translated(context, StockMessage::BackupTransferMsgBody)
|
||||
}
|
||||
|
||||
/// Stock string: `Proxy Enabled`.
|
||||
pub(crate) async fn proxy_enabled(context: &Context) -> String {
|
||||
translated(context, StockMessage::ProxyEnabled).await
|
||||
pub(crate) fn proxy_enabled(context: &Context) -> String {
|
||||
translated(context, StockMessage::ProxyEnabled)
|
||||
}
|
||||
|
||||
/// Stock string: `You are using a proxy. If you're having trouble connecting, try a different proxy.`.
|
||||
pub(crate) async fn proxy_description(context: &Context) -> String {
|
||||
translated(context, StockMessage::ProxyEnabledDescription).await
|
||||
pub(crate) fn proxy_description(context: &Context) -> String {
|
||||
translated(context, StockMessage::ProxyEnabledDescription)
|
||||
}
|
||||
|
||||
/// Stock string: `Messages in this chat use classic email and are not encrypted.`.
|
||||
pub(crate) async fn chat_unencrypted_explanation(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatUnencryptedExplanation).await
|
||||
pub(crate) fn chat_unencrypted_explanation(context: &Context) -> String {
|
||||
translated(context, StockMessage::ChatUnencryptedExplanation)
|
||||
}
|
||||
|
||||
/// Stock string: `You are using the legacy option "Move automatically to DeltaChat Folder`…
|
||||
pub(crate) async fn mvbox_move_deprecation(context: &Context) -> String {
|
||||
translated(context, StockMessage::MvboxMoveDeprecation).await
|
||||
pub(crate) fn mvbox_move_deprecation(context: &Context) -> String {
|
||||
translated(context, StockMessage::MvboxMoveDeprecation)
|
||||
}
|
||||
|
||||
impl Viewtype {
|
||||
/// returns Localized name for message viewtype
|
||||
pub async fn to_locale_string(&self, context: &Context) -> String {
|
||||
pub fn to_locale_string(&self, context: &Context) -> String {
|
||||
match self {
|
||||
Viewtype::Image => image(context).await,
|
||||
Viewtype::Gif => gif(context).await,
|
||||
Viewtype::Sticker => sticker(context).await,
|
||||
Viewtype::Audio => audio(context).await,
|
||||
Viewtype::Voice => voice_message(context).await,
|
||||
Viewtype::Video => video(context).await,
|
||||
Viewtype::File => file(context).await,
|
||||
Viewtype::Image => image(context),
|
||||
Viewtype::Gif => gif(context),
|
||||
Viewtype::Sticker => sticker(context),
|
||||
Viewtype::Audio => audio(context),
|
||||
Viewtype::Voice => voice_message(context),
|
||||
Viewtype::Video => video(context),
|
||||
Viewtype::File => file(context),
|
||||
Viewtype::Webxdc => "Mini App".to_owned(),
|
||||
Viewtype::Vcard => "👤".to_string(),
|
||||
// The following shouldn't normally be shown to users, so translations aren't needed.
|
||||
@@ -1323,10 +1252,9 @@ impl Viewtype {
|
||||
impl Context {
|
||||
/// Set the stock string for the [StockMessage].
|
||||
///
|
||||
pub async fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
|
||||
pub fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
|
||||
self.translated_stockstrings
|
||||
.set_stock_translation(id, stockstring)
|
||||
.await?;
|
||||
.set_stock_translation(id, stockstring)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1353,7 +1281,7 @@ impl Context {
|
||||
msg.param.set(Param::Filename, "welcome-image.jpg");
|
||||
chat::add_device_msg(self, Some("core-welcome-image"), Some(&mut msg)).await?;
|
||||
|
||||
let mut msg = Message::new_text(welcome_message(self).await);
|
||||
let mut msg = Message::new_text(welcome_message(self));
|
||||
chat::add_device_msg(self, Some("core-welcome"), Some(&mut msg)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1362,10 +1290,8 @@ impl Context {
|
||||
impl Accounts {
|
||||
/// Set the stock string for the [StockMessage].
|
||||
///
|
||||
pub async fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
|
||||
self.stockstrings
|
||||
.set_stock_translation(id, stockstring)
|
||||
.await?;
|
||||
pub fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
|
||||
self.stockstrings.set_stock_translation(id, stockstring)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,8 @@ fn test_fallback() {
|
||||
async fn test_set_stock_translation() {
|
||||
let t = TestContext::new().await;
|
||||
t.set_stock_translation(StockMessage::NoMessages, "xyz".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(no_messages(&t).await, "xyz")
|
||||
assert_eq!(no_messages(&t), "xyz")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -31,13 +30,11 @@ async fn test_set_stock_translation_wrong_replacements() {
|
||||
assert!(
|
||||
t.ctx
|
||||
.set_stock_translation(StockMessage::NoMessages, "xyz %1$s ".to_string())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
t.ctx
|
||||
.set_stock_translation(StockMessage::NoMessages, "xyz %2$s ".to_string())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
@@ -45,7 +42,7 @@ async fn test_set_stock_translation_wrong_replacements() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_stock_str() {
|
||||
let t = TestContext::new().await;
|
||||
assert_eq!(no_messages(&t).await, "No messages.");
|
||||
assert_eq!(no_messages(&t), "No messages.");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -56,17 +53,14 @@ async fn test_stock_string_repl_str() {
|
||||
.unwrap();
|
||||
let contact = Contact::get_by_id(&t.ctx, contact_id).await.unwrap();
|
||||
// uses %1$s substitution
|
||||
assert_eq!(contact_verified(&t, &contact).await, "Someone verified.");
|
||||
assert_eq!(contact_verified(&t, &contact), "Someone verified.");
|
||||
// We have no string using %1$d to test...
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_stock_system_msg_simple() {
|
||||
let t = TestContext::new().await;
|
||||
assert_eq!(
|
||||
msg_location_enabled(&t).await,
|
||||
"Location streaming enabled."
|
||||
)
|
||||
assert_eq!(msg_location_enabled(&t), "Location streaming enabled.")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -111,7 +105,7 @@ async fn test_stock_system_msg_add_member_by_other_with_displayname() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_quota_exceeding_stock_str() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
let str = quota_exceeding(&t, 81).await;
|
||||
let str = quota_exceeding(&t, 81);
|
||||
assert!(str.contains("81% "));
|
||||
assert!(str.contains("100% "));
|
||||
assert!(!str.contains("%%"));
|
||||
|
||||
@@ -98,13 +98,13 @@ impl Summary {
|
||||
contact: Option<&Contact>,
|
||||
) -> Result<Summary> {
|
||||
let prefix = if msg.state == MessageState::OutDraft {
|
||||
Some(SummaryPrefix::Draft(stock_str::draft(context).await))
|
||||
Some(SummaryPrefix::Draft(stock_str::draft(context)))
|
||||
} else if msg.from_id == ContactId::SELF {
|
||||
if msg.is_info() || msg.viewtype == Viewtype::Call || chat.typ == Chattype::OutBroadcast
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(SummaryPrefix::Me(stock_str::self_msg(context).await))
|
||||
Some(SummaryPrefix::Me(stock_str::self_msg(context)))
|
||||
}
|
||||
} else if chat.typ == Chattype::Group
|
||||
|| chat.typ == Chattype::Mailinglist
|
||||
@@ -124,7 +124,7 @@ impl Summary {
|
||||
let mut text = msg.get_summary_text(context).await;
|
||||
|
||||
if text.is_empty() && msg.quoted_text().is_some() {
|
||||
text = stock_str::reply_noun(context).await
|
||||
text = stock_str::reply_noun(context)
|
||||
}
|
||||
|
||||
let thumbnail_path = if msg.viewtype == Viewtype::Image
|
||||
@@ -160,7 +160,7 @@ impl Message {
|
||||
let summary = self.get_summary_text_without_prefix(context).await;
|
||||
|
||||
if self.is_forwarded() {
|
||||
format!("{}: {}", stock_str::forwarded(context).await, summary)
|
||||
format!("{}: {}", stock_str::forwarded(context), summary)
|
||||
} else {
|
||||
summary
|
||||
}
|
||||
@@ -180,43 +180,43 @@ impl Message {
|
||||
match viewtype {
|
||||
Viewtype::Image => {
|
||||
emoji = Some("📷");
|
||||
type_name = Some(stock_str::image(context).await);
|
||||
type_name = Some(stock_str::image(context));
|
||||
type_file = None;
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Gif => {
|
||||
emoji = None;
|
||||
type_name = Some(stock_str::gif(context).await);
|
||||
type_name = Some(stock_str::gif(context));
|
||||
type_file = None;
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Sticker => {
|
||||
emoji = None;
|
||||
type_name = Some(stock_str::sticker(context).await);
|
||||
type_name = Some(stock_str::sticker(context));
|
||||
type_file = None;
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Video => {
|
||||
emoji = Some("🎥");
|
||||
type_name = Some(stock_str::video(context).await);
|
||||
type_name = Some(stock_str::video(context));
|
||||
type_file = None;
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Voice => {
|
||||
emoji = Some("🎤");
|
||||
type_name = Some(stock_str::voice_message(context).await);
|
||||
type_name = Some(stock_str::voice_message(context));
|
||||
type_file = None;
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Audio => {
|
||||
emoji = Some("🎵");
|
||||
type_name = Some(stock_str::audio(context).await);
|
||||
type_name = Some(stock_str::audio(context));
|
||||
type_file = self.get_filename();
|
||||
append_text = true
|
||||
}
|
||||
Viewtype::File => {
|
||||
emoji = Some("📎");
|
||||
type_name = Some(stock_str::file(context).await);
|
||||
type_name = Some(stock_str::file(context));
|
||||
type_file = self.get_filename();
|
||||
append_text = true
|
||||
}
|
||||
@@ -255,14 +255,14 @@ impl Message {
|
||||
type_name = Some(match call_state {
|
||||
CallState::Alerting | CallState::Active | CallState::Completed { .. } => {
|
||||
if self.from_id == ContactId::SELF {
|
||||
stock_str::outgoing_call(context, has_video).await
|
||||
stock_str::outgoing_call(context, has_video)
|
||||
} else {
|
||||
stock_str::incoming_call(context, has_video).await
|
||||
stock_str::incoming_call(context, has_video)
|
||||
}
|
||||
}
|
||||
CallState::Missed => stock_str::missed_call(context).await,
|
||||
CallState::Declined => stock_str::declined_call(context).await,
|
||||
CallState::Canceled => stock_str::canceled_call(context).await,
|
||||
CallState::Missed => stock_str::missed_call(context),
|
||||
CallState::Declined => stock_str::declined_call(context),
|
||||
CallState::Canceled => stock_str::canceled_call(context),
|
||||
});
|
||||
type_file = None;
|
||||
append_text = false
|
||||
@@ -270,7 +270,7 @@ impl Message {
|
||||
Viewtype::Text | Viewtype::Unknown => {
|
||||
emoji = None;
|
||||
if self.param.get_cmd() == SystemMessage::LocationOnly {
|
||||
type_name = Some(stock_str::location(context).await);
|
||||
type_name = Some(stock_str::location(context));
|
||||
type_file = None;
|
||||
append_text = false;
|
||||
} else {
|
||||
|
||||
@@ -229,9 +229,9 @@ impl Context {
|
||||
let mut msg = Message {
|
||||
chat_id,
|
||||
viewtype: Viewtype::Text,
|
||||
text: stock_str::sync_msg_body(self).await,
|
||||
text: stock_str::sync_msg_body(self),
|
||||
hidden: true,
|
||||
subject: stock_str::sync_msg_subject(self).await,
|
||||
subject: stock_str::sync_msg_subject(self),
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::MultiDeviceSync);
|
||||
|
||||
@@ -118,7 +118,6 @@ impl TestContextManager {
|
||||
}
|
||||
|
||||
/// Returns new elena's "device".
|
||||
/// Elena doesn't send Intended Recipient Fingerprint subpackets to simulate old Delta Chat.
|
||||
pub async fn elena(&mut self) -> TestContext {
|
||||
TestContext::builder()
|
||||
.configure_elena()
|
||||
@@ -894,7 +893,7 @@ impl TestContext {
|
||||
///
|
||||
/// If the contact does not exist yet, a new contact will be created
|
||||
/// with the correct fingerprint, but without the public key.
|
||||
async fn add_or_lookup_contact_id_no_key(&self, other: &TestContext) -> ContactId {
|
||||
pub async fn add_or_lookup_contact_id_no_key(&self, other: &TestContext) -> ContactId {
|
||||
let primary_self_addr = other.ctx.get_primary_self_addr().await.unwrap();
|
||||
let addr = ContactAddress::new(&primary_self_addr).unwrap();
|
||||
let fingerprint = self_fingerprint(other).await.unwrap();
|
||||
|
||||
@@ -195,7 +195,7 @@ async fn test_degrade_verified_oneonone_chat() -> Result<()> {
|
||||
.await?;
|
||||
|
||||
let msg0 = get_chat_msg(&alice, alice_chat.id, 0, 1).await;
|
||||
let enabled = stock_str::messages_e2ee_info_msg(&alice).await;
|
||||
let enabled = stock_str::messages_e2ee_info_msg(&alice);
|
||||
assert_eq!(msg0.text, enabled);
|
||||
assert_eq!(msg0.param.get_cmd(), SystemMessage::ChatE2ee);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use std::time::SystemTime as Time;
|
||||
#[cfg(not(test))]
|
||||
pub use std::time::SystemTime;
|
||||
|
||||
use crate::log::LogExt as _;
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use base64::Engine as _;
|
||||
use chrono::{Local, NaiveDateTime, NaiveTime, TimeZone};
|
||||
@@ -233,8 +234,7 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
|
||||
|| "YY-MM-DD hh:mm:ss".to_string(),
|
||||
|ts| ts.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
if let Some(timestamp) = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0) {
|
||||
add_device_msg_with_importance(
|
||||
context,
|
||||
@@ -249,6 +249,8 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.context("Failed to add bad time warning")
|
||||
.log_err(context)
|
||||
.ok();
|
||||
} else {
|
||||
warn!(context, "Can't convert current timestamp");
|
||||
@@ -261,7 +263,7 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
|
||||
#[expect(clippy::arithmetic_side_effects)]
|
||||
async fn maybe_warn_on_outdated(context: &Context, now: i64, approx_compile_time: i64) {
|
||||
if now > approx_compile_time + DC_OUTDATED_WARNING_DAYS * 24 * 60 * 60 {
|
||||
let mut msg = Message::new_text(stock_str::update_reminder_msg_body(context).await);
|
||||
let mut msg = Message::new_text(stock_str::update_reminder_msg_body(context));
|
||||
if let Some(timestamp) = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0) {
|
||||
add_device_msg(
|
||||
context,
|
||||
|
||||
@@ -5,7 +5,7 @@ Some of the standards chatmail is based on:
|
||||
Tasks | Standards
|
||||
-------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
Transport | IMAP v4 ([RFC 3501][]), SMTP ([RFC 5321][]) and Internet Message Format (IMF, [RFC 5322][])
|
||||
Proxy | SOCKS5 ([RFC 1928][])
|
||||
Proxy | SOCKS5 ([RFC 1928][]), [Shadowsocks](https://github.com/Shadowsocks-NET/shadowsocks-specs)
|
||||
Embedded media | MIME Document Series ([RFC 2045][], [RFC 2046][]), Content-Disposition Header ([RFC 2183][]), Multipart/Related ([RFC 2387][])
|
||||
Text and Quote encoding | Fixed, Flowed ([RFC 3676][])
|
||||
Reactions | Reaction: Indicating Summary Reaction to a Message ([RFC 9078][])
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||
boundary="189f5bdd65baabe1_1081b3a339e9a68e_ec024374690079d2"
|
||||
MIME-Version: 1.0
|
||||
From: <alice@example.org>
|
||||
To: "hidden-recipients": ;
|
||||
Subject: [...]
|
||||
Date: Wed, 18 Mar 2026 14:03:13 +0000
|
||||
Message-ID: <4bc40798-0029-42ef-b34a-77866db439a5@localhost>
|
||||
Chat-Version: 1.0
|
||||
|
||||
|
||||
--189f5bdd65baabe1_1081b3a339e9a68e_ec024374690079d2
|
||||
Content-Type: application/pgp-encrypted; charset="utf-8"
|
||||
Content-Description: PGP/MIME version identification
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Version: 1
|
||||
|
||||
--189f5bdd65baabe1_1081b3a339e9a68e_ec024374690079d2
|
||||
Content-Type: application/octet-stream; name="encrypted.asc";
|
||||
charset="utf-8"
|
||||
Content-Description: OpenPGP encrypted message
|
||||
Content-Disposition: inline; filename="encrypted.asc";
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
-----BEGIN PGP MESSAGE-----
|
||||
|
||||
wU4DAAAAAAAAAAASAQdA297achK/ltriEs4OZi2IH+z7qzCFohr/zIzQP56gAlUg
|
||||
M+1o/VqAjq/vo1iWluO+q7OkQZ71F3svvCb8I8bEtijBwEwDAAAAAAAAAAABB/9z
|
||||
ZkrDMm+3gb6VeDe3QV5yLp8GcFHtOqPaIR2FsElDR1TmMJdCnQhZ7TvzYbeGbZ3M
|
||||
sqjYHtZLGLfrBiFwaygOFJRpS9mvtyb0q/PB2GteLWysJaBy3nZtk9ZmYPs2vqlw
|
||||
eVldQsNFkgAKVG6kaGTElXGGfraETvlCIyK2dglHXQJKmFEMno5OaFk/E7RP04CH
|
||||
hsnuDlTrKeppzgjmp6NFtV9vOkb5cCrK5L1Qo7BQgAFxEw5BgPBryxt2VkMdKMMZ
|
||||
BscDYnrrqFk8JLzlqrFxPvMWuwwyL0rVjau2rDuGMFEZQWfWkaJ2nPhicc4lpg4s
|
||||
qaRwFcbdtZ0jEqHBuHAS0sQmAWStScwsUHghlsk+93sRsRxP1+dqj3+kSC4Gzz79
|
||||
+ZSeStvk0Aa2khwmeFYNIzJIQUQpfwRDm7tnghnMjy7jDpn08yOpy3V1LR2PNGSz
|
||||
KtT7Q9F7rwNn2j/xs1hRAtp9n37fnlLZXyDQvOtsucKtYUiYydkWRfQdcVYem2Rt
|
||||
K7iT2kkgHbkbNP8Mk53VzF1sIE/0rnjtoDOTBQ/GAG7I9xxBjJ8bNoF0250E0cFL
|
||||
iHvsQRkIbLRip0qYuygsJ1zQgN4xkHYIY71iiiYUroK8PKDbAgyE/jz0kvfXqOdy
|
||||
6zjr+HIYb7jD2e7zNiI9pcHr3Or1mgZj5cJUktus2Kpnbz7lBhUMh2hBLCwaVUNv
|
||||
Pi0CIc+GMuwuLUOfvrT13E3gzY3a9NMQYORIrfE/I+4sy6urVWQkZarJol0xEJww
|
||||
BtKDyOJjdYSK59pT30wKLz0jy1G+XJ4yfqjf9kJlUwwHvWKpj0u8bAa0VrFlCjA2
|
||||
sLOgkKCYPHvaImGG0z4wrn4kbBtaiVcTGCF3fPOafkYqdKR9TaN3FBOiUz80ezli
|
||||
FLiyaH1/+VvzOmVHj8QWTViwkkO6Psvh/6m3eShqp2xekc82yWPer80xgIGipOxm
|
||||
wTeIwBb6x8nB5PuQloJF9rj6O7kYqw4R8bMRS8hs0DxuVACTYxpnDMPsQ65kFAmk
|
||||
6Qqzj5xpRGF10IOlMWtVYJIVvTKgnHwDvuWwLEEdgfGv0Edek3+VXIRAhySVJM01
|
||||
HJQ1cAHPUdQnksFr5cx5fZfTEdLsQ8onZDtwStptw38NVm3DxQiT0HYyjiilBzOU
|
||||
xFN8+Mm1hCYF69DdSD3xBK4fLHQ2DdKwHIdz1UxI5KyBBVek4rfhJsx2jGKoz68o
|
||||
l2ziNXgijwDlpZTqwxz+xHQWvw1L+GvP2HRfXVxLwJ61kZzKpEq8L01ZZlg+KI7C
|
||||
JQFHbdJOA2NRXZ1WoagJObPuGqLsRqHnZ3oDtpqJ1LvOp5KAvlq138+lkKPXo66s
|
||||
NPyExfm2+hjdKI6WqLNqa7sDBEsxqFkvfC84VhrKEvXn7oqmgtWBjmBcHA1O7haC
|
||||
xspncUcyLiksOeVdwJLRnCyZDtVB9VtXaeyJT6v2sCCU7gWaoMXFwX/68oKPkz6S
|
||||
xjWPkXPsBCBdXMS2ovINBwzhWeU9utluCLgk0g1rgAwZgPNhwpAZr9D74BSHohjD
|
||||
EYWGmBdz86ly6DB7UwYYheSzTwWSOb5qYuPfo1MxEVzrAgal5795zYAIv/dcofmc
|
||||
ahZ5JPXZgtywqMTAliKV24ENFZWSylE125zICX0vIKEr7zOowbENKBMoXFRqTMCh
|
||||
D8tdFdSlSLxMH2Enc3ndC4i5G7W/hZhFT7Lnpab9vWj2suVAvjrgOUCHPzXNY9Un
|
||||
RLCN4YfQelE9RAbaFYRVIE5XQUZJYMAf8kJvO3pWZ232a1m2Jp1GhfVo+/T/Y9qM
|
||||
3yxv8+rU62B0Eta8OBtcPweE/4X+vwV6GEMCGI4rhcaTohvXFp/XgOqAkXS3weaZ
|
||||
QwnmZQ66GsnjBlSbJsTmE3TgqzUrEFsVOOZ0Hc1elyZ6XvYdBR8XgDgaMkzu5M1r
|
||||
0JVJN5eMassObqhvZBa3uHlUEvoT2ufA3ue5iRapWqkfAafOzmrDLEH/OBkjff4F
|
||||
VEJ9Mqz+YT5A4e+3inYfrmfVdNHNmF4y
|
||||
=2p4+
|
||||
-----END PGP MESSAGE-----
|
||||
|
||||
|
||||
--189f5bdd65baabe1_1081b3a339e9a68e_ec024374690079d2--
|
||||
Reference in New Issue
Block a user