Compare commits

...

17 Commits

Author SHA1 Message Date
link2xt
cdfe436124 chore(release): prepare for 2.27.0 2025-11-16 06:34:11 +00:00
link2xt
e8823fcf35 test: test background_fetch() and stop_background_fetch() 2025-11-16 05:59:20 +00:00
link2xt
0136cfaf6a test: add pytest fixture for account manager 2025-11-16 05:59:20 +00:00
link2xt
07069c348b api(deltachat-rpc-client): add APIs for background fetch 2025-11-16 05:59:20 +00:00
Hocuri
26f6b85ff9 feat!: Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. (#7439)
Add the ability to withdraw broadcast invite codes

After merging:
- [x] Create issues in iOS, Desktop and UT repositories
2025-11-15 19:27:04 +01:00
Hocuri
10b6dd1f11 test(rpc-client): test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist (#7442)
Fix flaky test by calling `get_broadcast()` after the message events
were received.

Alternative to https://github.com/chatmail/core/pull/7437
2025-11-15 18:49:16 +01:00
B. Petersen
cae642b024 fix: send webm as file, it is not supported by all UI 2025-11-15 14:55:40 +01:00
B. Petersen
54a2e94525 fix: deprecate deletion timer string for '1 Minute'
the minimum timestamp in UI is 5 minutes
and the old string is about to be removed from translations.
the 'seconds' fallback is good enough, however
2025-11-15 14:41:54 +01:00
link2xt
9d4ad00fc0 build(nix): exclude CONTRIBUTING.md from the source files 2025-11-15 10:56:08 +00:00
Nico de Haen
102b72aadd fix: escape connectivity html 2025-11-14 22:50:15 +00:00
link2xt
1c4d2dd78e api: add APIs to stop background fetch
New APIs are JSON-RPC method stop_background_fetch(),
Rust method Accounts.stop_background_fetch()
and C method dc_accounts_stop_background_fetch().

These APIs allow to cancel background fetch early
even before the initially set timeout,
for example on Android when the system calls
`Service.onTimeout()` for a `dataSync` foreground service.
2025-11-14 22:48:19 +00:00
link2xt
cd50c263e8 api!(jsonrpc): rename accounts_background_fetch() into background_fetch()
There is no JSON-RPC method to run background_fetch() for a single account,
so no need to have a qualifier saying that it is for all accounts.
2025-11-14 22:48:19 +00:00
iequidoo
1dbcd7f1f4 test: HP-Outer headers are added to messages with standard Header Protection (#7130) 2025-11-14 19:45:32 -03:00
iequidoo
c6894f56b2 feat: Add Config::StdHeaderProtectionComposing (enables composing as defined in RFC 9788) (#7130)
And enable it by default as the standard Header Protection is backward-compatible.

Also this tests extra IMF header removal when a message has standard Header Protection since now we
can send such messages.
2025-11-14 19:45:32 -03:00
iequidoo
e2ae6ae013 feat: mimeparser: Omit Legacy Display Elements (#7130)
Omit Legacy Display Elements from "text/plain" and "text/html" (implement 4.5.3.{2,3} of
https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for Cryptographically Protected Email").
2025-11-14 19:45:32 -03:00
iequidoo
966ea28f83 feat: Ignore unprotected headers if Content-Type has "hp" parameter (#7130)
This is a part of implementation of https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for
Cryptographically Protected Email".
2025-11-14 19:45:32 -03:00
link2xt
6611a9fa02 fix: always set bcc_self on backup import/export
Regardless of whether chatmail relay is used or not,
bcc_self should be enabled when second device is added.
It should also be enabled again even if the user
has turned it off manually.
2025-11-14 20:00:34 +00:00
42 changed files with 792 additions and 137 deletions

View File

@@ -1,5 +1,46 @@
# Changelog
## [2.27.0] - 2025-11-16
### API-Changes
- Add APIs to stop background fetch.
- [**breaking**]: rename JSON-RPC method accounts_background_fetch() into background_fetch()
- rpc-client: Add APIs for background fetch.
- rpc-client: Add Account.wait_for_msg().
- Deprecate deletion timer string for '1 Minute'.
### Features / Changes
- Implement RFC 9788 (Header Protection for Cryptographically Protected Email) ([#7130](https://github.com/chatmail/core/pull/7130)).
- Tweak initial info-message for unencrypted chats ([#7427](https://github.com/chatmail/core/pull/7427)).
- Add Contact::get_or_gen_color. Use it in CFFI and JSON-RPC to avoid gray self-color ([#7374](https://github.com/chatmail/core/pull/7374)).
- [**breaking**] Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. ([#7439](https://github.com/chatmail/core/pull/7439)).
### Fixes
- Set `get_max_smtp_rcpt_to` for chatmail to the actual limit of 1000 instead of unlimited. ([#7432](https://github.com/chatmail/core/pull/7432)).
- Always set bcc_self on backup import/export.
- Escape connectivity HTML.
- Send webm as file, it is not supported by all UI.
### Build system
- nix: Exclude CONTRIBUTING.md from the source files.
### Refactor
- Use wait_for_incoming_msg() in more tests.
### Tests
- Fix flaky test_send_receive_locations.
- Port folder-related CFFI tests to JSON-RPC.
- HP-Outer headers are added to messages with standard Header Protection ([#7130](https://github.com/chatmail/core/pull/7130)).
- rpc-client: Test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist ([#7442](https://github.com/chatmail/core/pull/7442)).
- Add pytest fixture for account manager.
- Test background_fetch() and stop_background_fetch().
## [2.26.0] - 2025-11-11
### API-Changes
@@ -7143,3 +7184,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.24.0]: https://github.com/chatmail/core/compare/v2.23.0..v2.24.0
[2.25.0]: https://github.com/chatmail/core/compare/v2.24.0..v2.25.0
[2.26.0]: https://github.com/chatmail/core/compare/v2.25.0..v2.26.0
[2.27.0]: https://github.com/chatmail/core/compare/v2.26.0..v2.27.0

10
Cargo.lock generated
View File

@@ -1304,7 +1304,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.26.0"
version = "2.27.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1413,7 +1413,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.26.0"
version = "2.27.0"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1435,7 +1435,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.26.0"
version = "2.27.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1451,7 +1451,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.26.0"
version = "2.27.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1480,7 +1480,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.26.0"
version = "2.27.0"
dependencies = [
"anyhow",
"deltachat",

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.26.0"
version = "2.27.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"

View File

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

View File

@@ -2578,8 +2578,10 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_ERROR 400 // text1=error string
#define DC_QR_WITHDRAW_VERIFYCONTACT 500
#define DC_QR_WITHDRAW_VERIFYGROUP 502 // text1=groupname
#define DC_QR_WITHDRAW_JOINBROADCAST 504 // text1=broadcast name
#define DC_QR_REVIVE_VERIFYCONTACT 510
#define DC_QR_REVIVE_VERIFYGROUP 512 // text1=groupname
#define DC_QR_REVIVE_JOINBROADCAST 514 // text1=broadcast name
#define DC_QR_LOGIN 520 // text1=email_address
/**
@@ -3296,12 +3298,30 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts);
* without forgetting to create notifications caused by timing race conditions.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
* @param timeout The timeout in seconds
* @return Return 1 if DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE was emitted and 0 otherwise.
*/
int dc_accounts_background_fetch (dc_accounts_t* accounts, uint64_t timeout);
/**
* Stop ongoing background fetch.
*
* Calling this function allows to stop dc_accounts_background_fetch() early.
* dc_accounts_background_fetch() will then return immediately
* and emit DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE unless
* if it has failed and returned 0.
*
* If there is no ongoing dc_accounts_background_fetch() call,
* calling this function does nothing.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
*/
void dc_accounts_stop_background_fetch (dc_accounts_t *accounts);
/**
* Sets device token for Apple Push Notification service.
* Returns immediately.
@@ -7518,14 +7538,13 @@ void dc_event_unref(dc_event_t* event);
/// "You set message deletion timer to 1 minute."
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU 142
/// "Message deletion timer is set to 1 minute by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER 143
/// "You set message deletion timer to 1 hour."

View File

@@ -5027,6 +5027,17 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
1
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_stop_background_fetch(accounts: *mut dc_accounts_t) {
if accounts.is_null() {
eprintln!("ignoring careless call to dc_accounts_stop_background_fetch()");
return;
}
let accounts = &*accounts;
block_on(accounts.read()).stop_background_fetch();
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_set_push_device_token(
accounts: *mut dc_accounts_t,

View File

@@ -58,8 +58,10 @@ impl Lot {
Qr::Text { text } => Some(Cow::Borrowed(text)),
Qr::WithdrawVerifyContact { .. } => None,
Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::WithdrawJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::ReviveVerifyContact { .. } => None,
Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::ReviveJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::Login { address, .. } => Some(Cow::Borrowed(address)),
},
Self::Error(err) => Some(Cow::Borrowed(err)),
@@ -112,8 +114,10 @@ impl Lot {
Qr::Text { .. } => LotState::QrText,
Qr::WithdrawVerifyContact { .. } => LotState::QrWithdrawVerifyContact,
Qr::WithdrawVerifyGroup { .. } => LotState::QrWithdrawVerifyGroup,
Qr::WithdrawJoinBroadcast { .. } => LotState::QrWithdrawJoinBroadcast,
Qr::ReviveVerifyContact { .. } => LotState::QrReviveVerifyContact,
Qr::ReviveVerifyGroup { .. } => LotState::QrReviveVerifyGroup,
Qr::ReviveJoinBroadcast { .. } => LotState::QrReviveJoinBroadcast,
Qr::Login { .. } => LotState::QrLogin,
},
Self::Error(_err) => LotState::QrError,
@@ -138,9 +142,11 @@ impl Lot {
Qr::Url { .. } => Default::default(),
Qr::Text { .. } => Default::default(),
Qr::WithdrawVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::WithdrawVerifyGroup { .. } => Default::default(),
Qr::WithdrawVerifyGroup { .. } | Qr::WithdrawJoinBroadcast { .. } => {
Default::default()
}
Qr::ReviveVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::ReviveVerifyGroup { .. } => Default::default(),
Qr::ReviveVerifyGroup { .. } | Qr::ReviveJoinBroadcast { .. } => Default::default(),
Qr::Login { .. } => Default::default(),
},
Self::Error(_) => Default::default(),
@@ -207,11 +213,15 @@ pub enum LotState {
/// text1=groupname
QrWithdrawVerifyGroup = 502,
/// text1=broadcast channel name
QrWithdrawJoinBroadcast = 504,
QrReviveVerifyContact = 510,
/// text1=groupname
QrReviveVerifyGroup = 512,
/// text1=groupname
QrReviveJoinBroadcast = 514,
/// text1=email_address
QrLogin = 520,

View File

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

View File

@@ -273,7 +273,7 @@ impl CommandApi {
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout.
/// Process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
async fn accounts_background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
let future = {
let lock = self.accounts.read().await;
lock.background_fetch(std::time::Duration::from_secs_f64(timeout_in_seconds))
@@ -283,6 +283,11 @@ impl CommandApi {
Ok(())
}
async fn stop_background_fetch(&self) -> Result<()> {
self.accounts.read().await.stop_background_fetch();
Ok(())
}
// ---------------------------------------------
// Methods that work on individual accounts
// ---------------------------------------------

View File

@@ -157,6 +157,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to withdraw their own broadcast channel invite QR code.
WithdrawJoinBroadcast {
/// Broadcast name.
name: String,
/// ID, uniquely identifying this chat. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own QR code.
ReviveVerifyContact {
/// Contact ID.
@@ -183,6 +198,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own broadcast channel invite QR code.
ReviveJoinBroadcast {
/// Broadcast name.
name: String,
/// Globally unique chat ID. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// `dclogin:` scheme parameters.
///
/// Ask the user if they want to login with the email address.
@@ -306,6 +336,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::ReviveVerifyContact {
contact_id,
fingerprint,
@@ -340,6 +389,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::Login { address, .. } => QrObject::Login { address },
}
}

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.26.0"
version = "2.27.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ._utils import AttrDict
from ._utils import AttrDict, futuremethod
from .account import Account
if TYPE_CHECKING:
@@ -39,6 +39,15 @@ class DeltaChat:
"""Stop the I/O of all accounts."""
self.rpc.stop_io_for_all_accounts()
@futuremethod
def background_fetch(self, timeout_in_seconds: int) -> None:
"""Run background fetch for all accounts."""
yield self.rpc.background_fetch.future(timeout_in_seconds)
def stop_background_fetch(self) -> None:
"""Stop ongoing background fetch."""
self.rpc.stop_background_fetch()
def maybe_network(self) -> None:
"""Indicate that the network conditions might have changed."""
self.rpc.maybe_network()

View File

@@ -135,9 +135,15 @@ def rpc(tmp_path) -> AsyncGenerator:
@pytest.fixture
def acfactory(rpc) -> AsyncGenerator:
def dc(rpc) -> DeltaChat:
"""Return account manager."""
return DeltaChat(rpc)
@pytest.fixture
def acfactory(dc) -> AsyncGenerator:
"""Return account factory fixture."""
return ACFactory(DeltaChat(rpc))
return ACFactory(dc)
@pytest.fixture

View File

@@ -4,6 +4,41 @@ from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import MessageState
def test_bcc_self_delete_server_after_defaults(acfactory):
"""Test default values for bcc_self and delete_server_after."""
ac = acfactory.get_online_account()
# Initially after getting online
# the setting bcc_self is set to 0 because there is only one device
# and delete_server_after is "1", meaning immediate deletion.
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Setup a second device.
ac_clone = ac.clone()
ac_clone.bring_online()
# Second device setup
# enables bcc_self and changes default delete_server_after.
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
assert ac_clone.get_config("bcc_self") == "1"
assert ac_clone.get_config("delete_server_after") == "0"
# Manually disabling bcc_self
# also restores the default for delete_server_after.
ac.set_config("bcc_self", "0")
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Cloning the account again enables bcc_self
# even though it was manually disabled.
ac_clone = ac.clone()
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()

View File

@@ -140,15 +140,15 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
return chat
def wait_for_broadcast_messages(ac):
snapshot1 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot1.text == "You joined the channel."
snapshot2 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot2.text == "Hello everyone!"
chat = get_broadcast(ac)
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You joined the channel."
assert snapshot.chat_id == chat.id
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!"
assert snapshot.chat_id == chat.id
assert snapshot1.chat_id == chat.id
assert snapshot2.chat_id == chat.id
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
# Check that the chat partner is verified.

View File

@@ -965,3 +965,40 @@ def test_immediate_autodelete(acfactory, direct_imap, log):
ev = ac1.wait_for_event(EventType.MSG_READ)
assert ev.chat_id == chat1.id
assert ev.msg_id == sent_msg.id
def test_background_fetch(acfactory, dc):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.stop_io()
ac1_chat = ac1.create_chat(ac2)
ac2_chat = ac2.create_chat(ac1)
ac2_chat.send_text("Hello!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello!":
break
# Stopping background fetch immediately after starting
# does not result in any errors.
background_fetch_future = dc.background_fetch.future(300)
dc.stop_background_fetch()
background_fetch_future()
# Starting background fetch with zero timeout is ok,
# it should terminate immediately.
dc.background_fetch(0)
# Background fetch can still be used to send and receive messages.
ac2_chat.send_text("Hello again!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello again!":
break

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.26.0"
version = "2.27.0"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.26.0"
"version": "2.27.0"
}

View File

@@ -34,7 +34,6 @@
./Cargo.lock
./Cargo.toml
./CMakeLists.txt
./CONTRIBUTING.md
./deltachat_derive
./deltachat-contact-tools
./deltachat-ffi

View File

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

View File

@@ -1 +1 @@
2025-11-11
2025-11-16

View File

@@ -3,8 +3,12 @@
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self, Receiver, Sender};
use futures::FutureExt as _;
use futures_lite::FutureExt as _;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -41,6 +45,13 @@ pub struct Accounts {
/// Push notification subscriber shared between accounts.
push_subscriber: PushSubscriber,
/// Channel sender to cancel ongoing background_fetch().
///
/// If background_fetch() is not running, this is `None`.
/// New background_fetch() should not be started if this
/// contains `Some`.
background_fetch_interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
}
impl Accounts {
@@ -96,6 +107,7 @@ impl Accounts {
events,
stockstrings,
push_subscriber,
background_fetch_interrupt_sender: Default::default(),
})
}
@@ -352,6 +364,11 @@ impl Accounts {
///
/// This is an auxiliary function and not part of public API.
/// Use [Accounts::background_fetch] instead.
///
/// This function is cancellation-safe.
/// It is intended to be cancellable,
/// either because of the timeout or because background
/// fetch was explicitly cancelled.
async fn background_fetch_no_timeout(accounts: Vec<Context>, events: Events) {
let n_accounts = accounts.len();
events.emit(Event {
@@ -378,14 +395,33 @@ impl Accounts {
}
/// Auxiliary function for [Accounts::background_fetch].
///
/// Runs `background_fetch` until it finishes
/// or until the timeout.
///
/// Produces `AccountsBackgroundFetchDone` event in every case
/// and clears [`Self::background_fetch_interrupt_sender`]
/// so a new background fetch can be started.
///
/// This function is not cancellation-safe.
/// Cancelling it before it returns may result
/// in not being able to run any new background fetch
/// if interrupt sender was not cleared.
async fn background_fetch_with_timeout(
accounts: Vec<Context>,
events: Events,
timeout: std::time::Duration,
interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
interrupt_receiver: Option<Receiver<()>>,
) {
let Some(interrupt_receiver) = interrupt_receiver else {
// Nothing to do if we got no interrupt receiver.
return;
};
if let Err(_err) = tokio::time::timeout(
timeout,
Self::background_fetch_no_timeout(accounts, events.clone()),
Self::background_fetch_no_timeout(accounts, events.clone())
.race(interrupt_receiver.recv().map(|_| ())),
)
.await
{
@@ -398,10 +434,16 @@ impl Accounts {
id: 0,
typ: EventType::AccountsBackgroundFetchDone,
});
(*interrupt_sender.lock()) = None;
}
/// Performs a background fetch for all accounts in parallel with a timeout.
///
/// Ongoing background fetch can also be cancelled manually
/// by calling `stop_background_fetch()`, in which case it will
/// return immediately even before the timeout expiration
/// or finishing fetching.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end,
/// process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
@@ -414,7 +456,39 @@ impl Accounts {
) -> impl Future<Output = ()> + use<> {
let accounts: Vec<Context> = self.accounts.values().cloned().collect();
let events = self.events.clone();
Self::background_fetch_with_timeout(accounts, events, timeout)
let (sender, receiver) = async_channel::bounded(1);
let receiver = {
let mut lock = self.background_fetch_interrupt_sender.lock();
if (*lock).is_some() {
// Another background_fetch() is already running,
// return immeidately.
None
} else {
*lock = Some(sender);
Some(receiver)
}
};
Self::background_fetch_with_timeout(
accounts,
events,
timeout,
self.background_fetch_interrupt_sender.clone(),
receiver,
)
}
/// Interrupts ongoing background_fetch() call,
/// making it return early.
///
/// This method allows to cancel background_fetch() early,
/// e.g. on Android, when `Service.onTimeout` is called.
///
/// If there is no ongoing background_fetch(), does nothing.
pub fn stop_background_fetch(&self) {
let mut lock = self.background_fetch_interrupt_sender.lock();
if let Some(sender) = lock.take() {
sender.try_send(()).ok();
}
}
/// Emits a single event.

View File

@@ -2632,12 +2632,6 @@ async fn test_can_send_group() -> Result<()> {
/// the recipients can't see the identity of their fellow recipients.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
fn contains(parsed: &MimeMessage, s: &str) -> bool {
assert_eq!(parsed.decrypting_failed, false);
let decoded_str = std::str::from_utf8(&parsed.decoded_data).unwrap();
decoded_str.contains(s)
}
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
@@ -2669,8 +2663,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
);
let parsed = charlie.parse_msg(&auth_required).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&auth_required).await;
assert!(parsed_by_bob.decrypting_failed);
@@ -2698,8 +2692,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
);
let parsed = charlie.parse_msg(&member_added).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_added).await;
assert!(parsed_by_bob.decrypting_failed);
@@ -2713,8 +2707,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
let hi_msg = alice.send_text(alice_broadcast_id, "hi").await;
let parsed = charlie.parse_msg(&hi_msg).await;
assert_eq!(parsed.header_exists(HeaderDef::AutocryptGossip), false);
assert_eq!(contains(&parsed, "charlie@example.net"), false);
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&hi_msg).await;
assert_eq!(parsed_by_bob.decrypting_failed, false);
@@ -2730,8 +2724,8 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
"charlie@example.net alice@example.org"
);
let parsed = charlie.parse_msg(&member_removed).await;
assert!(contains(&parsed, "charlie@example.net"));
assert_eq!(contains(&parsed, "bob@example.net"), false);
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_removed).await;
assert!(parsed_by_bob.decrypting_failed);

View File

@@ -438,8 +438,19 @@ pub enum Config {
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
/// Enables running test hooks, e.g. see `InnerContext::pre_encrypt_mime_hook`.
/// This way is better than conditional compilation, i.e. `#[cfg(test)]`, because tests not
/// using this still run unmodified code.
TestHooks,
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
FailOnReceivingFullMsg,
/// Enable composing emails with Header Protection as defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for Cryptographically
/// Protected Email".
#[strum(props(default = "1"))]
StdHeaderProtectionComposing,
}
impl Config {

View File

@@ -303,6 +303,17 @@ pub struct InnerContext {
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
#[expect(clippy::type_complexity)]
/// Transforms the root of the cryptographic payload before encryption.
pub(crate) pre_encrypt_mime_hook: parking_lot::Mutex<
Option<
for<'a> fn(
&Context,
mail_builder::mime::MimePart<'a>,
) -> mail_builder::mime::MimePart<'a>,
>,
>,
}
/// The state of ongoing process.
@@ -467,6 +478,7 @@ impl Context {
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};
let ctx = Context {
@@ -1051,6 +1063,13 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"test_hooks",
self.sql
.get_raw_config("test_hooks")
.await?
.unwrap_or_default(),
);
res.insert(
"fail_on_receiving_full_msg",
self.sql
@@ -1058,6 +1077,13 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"std_header_protection_composing",
self.sql
.get_raw_config("std_header_protection_composing")
.await?
.unwrap_or_default(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));

View File

@@ -13,6 +13,7 @@ use quick_xml::{
use crate::simplify::{SimplifiedText, simplify_quote};
#[derive(Default)]
struct Dehtml {
strbuilder: String,
quote: String,
@@ -25,6 +26,9 @@ struct Dehtml {
/// Everything between `<div name="quote">` and `<div name="quoted-content">` is usually metadata
/// If this is > `0`, then we are inside a `<div name="quoted-content">`.
divs_since_quoted_content_div: u32,
/// `<div class="header-protection-legacy-display">` elements should be omitted, see
/// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>.
divs_since_hp_legacy_display: u32,
/// All-Inkl just puts the quote into `<blockquote> </blockquote>`. This count is
/// increased at each `<blockquote>` and decreased at each `</blockquote>`.
blockquotes_since_blockquote: u32,
@@ -48,20 +52,25 @@ impl Dehtml {
}
fn get_add_text(&self) -> AddText {
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0 {
AddText::No // Everything between `<div name="quoted">` and `<div name="quoted_content">` is metadata which we don't want
// Everything between `<div name="quoted">` and `<div name="quoted_content">` is
// metadata which we don't want.
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0
|| self.divs_since_hp_legacy_display > 0
{
AddText::No
} else {
self.add_text
}
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
#[derive(Debug, Default, PartialEq, Clone, Copy)]
enum AddText {
/// Inside `<script>`, `<style>` and similar tags
/// which contents should not be displayed.
No,
#[default]
YesRemoveLineEnds,
/// Inside `<pre>`.
@@ -121,12 +130,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
let mut dehtml = Dehtml {
strbuilder: String::with_capacity(buf.len()),
quote: String::new(),
add_text: AddText::YesRemoveLineEnds,
last_href: None,
divs_since_quote_div: 0,
divs_since_quoted_content_div: 0,
blockquotes_since_blockquote: 0,
..Default::default()
};
let mut reader = quick_xml::Reader::from_str(buf);
@@ -244,6 +248,7 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
"div" => {
pop_tag(&mut dehtml.divs_since_quote_div);
pop_tag(&mut dehtml.divs_since_quoted_content_div);
pop_tag(&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -295,6 +300,8 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
"div" => {
maybe_push_tag(event, reader, "quote", &mut dehtml.divs_since_quote_div);
maybe_push_tag(event, reader, "quoted-content", &mut dehtml.divs_since_quoted_content_div);
maybe_push_tag(event, reader, "header-protection-legacy-display",
&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -539,6 +546,27 @@ mod tests {
assert_eq!(txt.text.trim(), "two\nlines");
}
#[test]
fn test_hp_legacy_display() {
let input = r#"
<html><head><title></title></head><body>
<div class="header-protection-legacy-display">
<pre>Subject: Dinner plans</pre>
</div>
<p>
Let's meet at Rama's Roti Shop at 8pm and go to the park
from there.
</p>
</body>
</html>
"#;
let txt = dehtml(input).unwrap();
assert_eq!(
txt.text.trim(),
"Let's meet at Rama's Roti Shop at 8pm and go to the park from there."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote_div() {
let input = include_str!("../test-data/message/gmx-quote-body.eml");

View File

@@ -241,10 +241,9 @@ pub(crate) async fn stock_ephemeral_timer_changed(
match timer {
Timer::Disabled => stock_str::msg_ephemeral_timer_disabled(context, from_id).await,
Timer::Enabled { duration } => match duration {
0..=59 => {
0..=60 => {
stock_str::msg_ephemeral_timer_enabled(context, &timer.to_string(), from_id).await
}
60 => stock_str::msg_ephemeral_timer_minute(context, from_id).await,
61..=3599 => {
stock_str::msg_ephemeral_timer_minutes(
context,

View File

@@ -38,7 +38,7 @@ async fn test_stock_ephemeral_messages() {
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 60 }, ContactId::SELF)
.await,
"You set message deletion timer to 1 minute."
"You set message deletion timer to 60 s."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 90 }, ContactId::SELF)
@@ -142,7 +142,7 @@ async fn test_ephemeral_enable_disable() -> Result<()> {
let bob_received_message = bob.recv_msg(&sent).await;
assert_eq!(
bob_received_message.text,
"Message deletion timer is set to 1 minute by alice@example.org."
"Message deletion timer is set to 60 s by alice@example.org."
);
assert_eq!(
chat_bob.get_ephemeral_timer(bob).await?,

View File

@@ -138,6 +138,9 @@ pub enum HeaderDef {
/// Advertised gossip topic for one webxdc.
IrohGossipTopic,
/// See <https://www.rfc-editor.org/rfc/rfc9788.html#name-hp-outer-header-field>.
HpOuter,
#[cfg(test)]
TestHeader,
}

View File

@@ -377,7 +377,15 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
res = check_backup_version(context).await;
}
if res.is_ok() {
res = adjust_bcc_self(context).await;
// All recent backups have `bcc_self` set to "1" before export.
//
// Setting `bcc_self` to "1" on export was introduced on 2024-12-17
// in commit 21664125d798021be75f47d5b0d5006d338b4531
//
// We additionally try to set `bcc_self` to "1" after import here
// for compatibility with older backups,
// but eventually this code can be removed.
res = context.set_config(Config::BccSelf, Some("1")).await;
}
fs::remove_file(unpacked_database)
.await
@@ -751,7 +759,7 @@ async fn export_database(
.to_str()
.with_context(|| format!("path {} is not valid unicode", dest.display()))?;
adjust_bcc_self(context).await?;
context.set_config(Config::BccSelf, Some("1")).await?;
context
.sql
.set_raw_config_int("backup_time", timestamp)
@@ -785,18 +793,6 @@ async fn export_database(
.await
}
/// Sets `Config::BccSelf` (and `DeleteServerAfter` to "never" in effect) if needed so that new
/// messages are present on the server after a backup restoration or available for all devices in
/// multi-device case. NB: Calling this after a backup import isn't reliable as we can crash in
/// between, but this is a problem only for old backups, new backups already have `BccSelf` set if
/// necessary.
async fn adjust_bcc_self(context: &Context) -> Result<()> {
if context.is_chatmail().await? && !context.config_exists(Config::BccSelf).await? {
context.set_config(Config::BccSelf, Some("1")).await?;
}
Ok(())
}
async fn check_backup_version(context: &Context) -> Result<()> {
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
ensure!(

View File

@@ -1440,7 +1440,15 @@ pub(crate) fn guess_msgtype_from_path_suffix(path: &Path) -> Option<(Viewtype, &
let extension: &str = &path.extension()?.to_str()?.to_lowercase();
let info = match extension {
// before using viewtype other than Viewtype::File,
// make sure, all target UIs support that type in the context of the used viewer/player.
// make sure, all target UIs support that type.
//
// it is a non-goal to support as many formats as possible in-app.
// additional parser come at security and maintainance costs and
// should only be added when strictly neccessary,
// eg. when a format comes from the camera app on a significant number of devices.
// it is okay, when eg. dragging some video from a browser results in a "File"
// for everyone, sender as well as all receivers.
//
// if in doubt, it is better to default to Viewtype::File that passes handing to an external app.
// (cmp. <https://developer.android.com/guide/topics/media/media-formats>)
"3gp" => (Viewtype::Video, "video/3gpp"),
@@ -1503,7 +1511,7 @@ pub(crate) fn guess_msgtype_from_path_suffix(path: &Path) -> Option<(Viewtype, &
"vcf" => (Viewtype::Vcard, "text/vcard"),
"wav" => (Viewtype::Audio, "audio/wav"),
"weba" => (Viewtype::File, "audio/webm"),
"webm" => (Viewtype::Video, "video/webm"),
"webm" => (Viewtype::File, "video/webm"), // not supported natively by iOS nor by SDWebImage
"webp" => (Viewtype::Image, "image/webp"), // iOS via SDWebImage, Android since 4.0
"wmv" => (Viewtype::Video, "video/x-ms-wmv"),
"xdc" => (Viewtype::Webxdc, "application/webxdc+zip"),

View File

@@ -1083,6 +1083,9 @@ impl MimeFactory {
}
}
let use_std_header_protection = context
.get_config_bool(Config::StdHeaderProtectionComposing)
.await?;
let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys {
// Store protected headers in the inner message.
let message = protected_headers
@@ -1098,6 +1101,22 @@ impl MimeFactory {
message.header(header, value)
});
if use_std_header_protection {
message = unprotected_headers
.iter()
// Structural headers shouldn't be added as "HP-Outer". They are defined in
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
.filter(|(name, _)| {
!(name.eq_ignore_ascii_case("mime-version")
|| name.eq_ignore_ascii_case("content-type")
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|| name.eq_ignore_ascii_case("content-disposition"))
})
.fold(message, |message, (name, value)| {
message.header(format!("HP-Outer: {name}"), value.clone())
});
}
// Add gossip headers in chats with multiple recipients
let multiple_recipients =
encryption_pubkeys.len() > 1 || context.get_config_bool(Config::BccSelf).await?;
@@ -1187,7 +1206,13 @@ impl MimeFactory {
for (h, v) in &mut message.headers {
if h == "Content-Type" {
if let mail_builder::headers::HeaderType::ContentType(ct) = v {
*ct = ct.clone().attribute("protected-headers", "v1");
let mut ct_new = ct.clone();
ct_new = ct_new.attribute("protected-headers", "v1");
if use_std_header_protection {
ct_new = ct_new.attribute("hp", "cipher");
}
*ct = ct_new;
break;
}
}
}
@@ -1232,6 +1257,12 @@ impl MimeFactory {
// once new core versions are sufficiently deployed.
let anonymous_recipients = false;
if context.get_config_bool(Config::TestHooks).await? {
if let Some(hook) = &*context.pre_encrypt_mime_hook.lock() {
message = hook(context, message);
}
}
let encrypted = if let Some(shared_secret) = shared_secret {
encrypt_helper
.encrypt_symmetrically(context, &shared_secret, message, compress)
@@ -1319,7 +1350,13 @@ impl MimeFactory {
for (h, v) in &mut message.headers {
if h == "Content-Type" {
if let mail_builder::headers::HeaderType::ContentType(ct) = v {
*ct = ct.clone().attribute("protected-headers", "v1");
let mut ct_new = ct.clone();
ct_new = ct_new.attribute("protected-headers", "v1");
if use_std_header_protection {
ct_new = ct_new.attribute("hp", "clear");
}
*ct = ct_new;
break;
}
}
}

View File

@@ -832,8 +832,32 @@ async fn test_protected_headers_directive() -> Result<()> {
.count(),
1
);
assert_eq!(part.match_indices("Subject:").count(), 1);
assert_eq!(part.match_indices("Subject:").count(), 2);
assert_eq!(part.match_indices("HP-Outer: Subject:").count(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hp_outer_headers() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = t.get_self_chat().await.id;
for std_hp_composing in [false, true] {
t.set_config_bool(Config::StdHeaderProtectionComposing, std_hp_composing)
.await?;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
let msg = MimeMessage::from_bytes(t, sent_msg.payload.as_bytes(), None).await?;
assert_eq!(msg.header_exists(HeaderDef::HpOuter), std_hp_composing);
for hdr in ["Date", "From", "Message-ID"] {
assert_eq!(
msg.decoded_data_contains(&format!("HP-Outer: {hdr}:")),
std_hp_composing,
);
}
assert!(!msg.decoded_data_contains("HP-Outer: Content-Type"));
}
Ok(())
}

View File

@@ -271,7 +271,7 @@ impl MimeMessage {
&mut from,
&mut list_post,
&mut chat_disposition_notification_to,
&mail.headers,
&mail,
);
headers.retain(|k, _| {
!is_hidden(k) || {
@@ -299,7 +299,7 @@ impl MimeMessage {
&mut from,
&mut list_post,
&mut chat_disposition_notification_to,
&part.headers,
part,
);
(part, part.ctype.mimetype.parse::<Mime>()?)
} else {
@@ -536,7 +536,7 @@ impl MimeMessage {
&mut inner_from,
&mut list_post,
&mut chat_disposition_notification_to,
&mail.headers,
mail,
);
if !signatures.is_empty() {
@@ -1007,6 +1007,14 @@ impl MimeMessage {
self.headers.contains_key(hname) || self.headers_removed.contains(hname)
}
#[cfg(test)]
/// Returns whether the decrypted data contains the given `&str`.
pub(crate) fn decoded_data_contains(&self, s: &str) -> bool {
assert!(!self.decrypting_failed);
let decoded_str = str::from_utf8(&self.decoded_data).unwrap();
decoded_str.contains(s)
}
/// Returns `Chat-Group-ID` header value if it is a valid group ID.
pub fn get_chat_group_id(&self) -> Option<&str> {
self.get_header(HeaderDef::ChatGroupId)
@@ -1323,6 +1331,10 @@ impl MimeMessage {
let is_html = mime_type == mime::TEXT_HTML;
if is_html {
self.is_mime_modified = true;
// NB: This unconditionally removes Legacy Display Elements (see
// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>). We
// don't check for the "hp-legacy-display" Content-Type parameter
// for simplicity.
if let Some(text) = dehtml(&decoded_data) {
text
} else {
@@ -1350,16 +1362,30 @@ impl MimeMessage {
let (simplified_txt, simplified_quote) = if mime_type.type_() == mime::TEXT
&& mime_type.subtype() == mime::PLAIN
&& is_format_flowed
{
let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
// Don't check that we're inside an encrypted or signed part for
// simplicity.
let simplified_txt = match mail
.ctype
.params
.get("hp-legacy-display")
.is_some_and(|v| v == "1")
{
false => simplified_txt,
true => rm_legacy_display_elements(&simplified_txt),
};
let unflowed_text = unformat_flowed(&simplified_txt, delsp);
let unflowed_quote = top_quote.map(|q| unformat_flowed(&q, delsp));
(unflowed_text, unflowed_quote)
if is_format_flowed {
let delsp = if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
};
let unflowed_text = unformat_flowed(&simplified_txt, delsp);
let unflowed_quote = top_quote.map(|q| unformat_flowed(&q, delsp));
(unflowed_text, unflowed_quote)
} else {
(simplified_txt, top_quote)
}
} else {
(simplified_txt, top_quote)
};
@@ -1632,6 +1658,11 @@ impl MimeMessage {
}
}
/// Merges headers from the email `part` into `headers` respecting header protection.
/// Should only be called with nonempty `headers` if `part` is a root of the Cryptographic
/// Payload as defined in <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for
/// Cryptographically Protected Email", otherwise this may unnecessarily discard headers from
/// outer parts.
#[allow(clippy::too_many_arguments)]
fn merge_headers(
context: &Context,
@@ -1642,10 +1673,14 @@ impl MimeMessage {
from: &mut Option<SingleInfo>,
list_post: &mut Option<String>,
chat_disposition_notification_to: &mut Option<SingleInfo>,
fields: &[mailparse::MailHeader<'_>],
part: &mailparse::ParsedMail,
) {
let fields = &part.headers;
// See <https://www.rfc-editor.org/rfc/rfc9788.html>.
let has_header_protection = part.ctype.params.contains_key("hp");
headers.retain(|k, _| {
!is_protected(k) || {
!(has_header_protection || is_protected(k)) || {
headers_removed.insert(k.to_string());
false
}
@@ -1972,6 +2007,20 @@ impl MimeMessage {
}
}
fn rm_legacy_display_elements(text: &str) -> String {
let mut res = None;
for l in text.lines() {
res = res.map(|r: String| match r.is_empty() {
true => l.to_string(),
false => r + "\r\n" + l,
});
if l.is_empty() {
res = Some(String::new());
}
}
res.unwrap_or_default()
}
fn remove_header(
headers: &mut HashMap<String, String>,
key: &str,
@@ -2096,7 +2145,8 @@ pub(crate) fn parse_message_id(ids: &str) -> Result<String> {
}
/// Returns whether the outer header value must be ignored if the message contains a signed (and
/// optionally encrypted) part.
/// optionally encrypted) part. This is independent from the modern Header Protection defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html>.
///
/// NB: There are known cases when Subject and List-ID only appear in the outer headers of
/// signed-only messages. Such messages are shown as unencrypted anyway.

View File

@@ -1401,22 +1401,28 @@ async fn test_x_microsoft_original_message_id_precedence() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_extra_imf_chat_header() -> Result<()> {
async fn test_extra_imf_headers() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = t.get_self_chat().await.id;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
// Check removal of some nonexistent "Chat-*" header to protect the code from future breakages.
let payload = sent_msg
.payload
.replace("Message-ID:", "Chat-Forty-Two: 42\r\nMessage-ID:");
let msg = MimeMessage::from_bytes(t, payload.as_bytes(), None)
.await
.unwrap();
assert!(msg.headers.contains_key("chat-version"));
assert!(!msg.headers.contains_key("chat-forty-two"));
for std_hp_composing in [false, true] {
t.set_config_bool(Config::StdHeaderProtectionComposing, std_hp_composing)
.await?;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
// Check removal of some nonexistent "Chat-*" header to protect the code from future
// breakages. But headers not prefixed with "Chat-" remain unless a message has standard
// Header Protection.
let payload = sent_msg.payload.replace(
"Message-ID:",
"Chat-Forty-Two: 42\r\nForty-Two: 42\r\nMessage-ID:",
);
let msg = MimeMessage::from_bytes(t, payload.as_bytes(), None).await?;
assert!(msg.headers.contains_key("chat-version"));
assert!(!msg.headers.contains_key("chat-forty-two"));
assert_ne!(msg.headers.contains_key("forty-two"), std_hp_composing);
}
Ok(())
}
@@ -1751,6 +1757,39 @@ async fn test_time_in_future() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hp_legacy_display() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let mut msg = Message::new_text(
"Subject: Dinner plans\n\
\n\
Let's eat"
.to_string(),
);
msg.set_subject("Dinner plans".to_string());
let chat_id = alice.create_chat(bob).await.id;
alice.set_config_bool(Config::TestHooks, true).await?;
*alice.pre_encrypt_mime_hook.lock() = Some(|_, mut mime| {
for (h, v) in &mut mime.headers {
if h == "Content-Type" {
if let mail_builder::headers::HeaderType::ContentType(ct) = v {
*ct = ct.clone().attribute("hp-legacy-display", "1");
}
}
}
mime
});
let sent_msg = alice.send_msg(chat_id, &mut msg).await;
let msg_bob = bob.recv_msg(&sent_msg).await;
assert_eq!(msg_bob.subject, "Dinner plans");
assert_eq!(msg_bob.text, "Let's eat");
Ok(())
}
/// Tests that subject is not prepended to the message
/// when bot receives it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

104
src/qr.rs
View File

@@ -233,6 +233,31 @@ pub enum Qr {
authcode: String,
},
/// Ask the user if they want to withdraw their own broadcast channel invite QR code.
WithdrawJoinBroadcast {
/// The user-visible name of this broadcast channel
name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel across all databases/clients.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: ContactId,
/// Fingerprint of the contact's key as scanned from the QR code.
fingerprint: Fingerprint,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own QR code.
ReviveVerifyContact {
/// Contact ID.
@@ -269,6 +294,31 @@ pub enum Qr {
authcode: String,
},
/// Ask the user if they want to revive their own broadcast channel invite QR code.
ReviveJoinBroadcast {
/// The user-visible name of this broadcast channel
name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel across all databases/clients.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: ContactId,
/// Fingerprint of the contact's key as scanned from the QR code.
fingerprint: Fingerprint,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// `dclogin:` scheme parameters.
///
/// Ask the user if they want to login with the email address.
@@ -500,14 +550,40 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
})
}
} else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
Ok(Qr::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
if context
.is_self_addr(&addr)
.await
.with_context(|| format!("Can't check if {addr:?} is our address"))?
{
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
Ok(Qr::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
} else {
Ok(Qr::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else {
Ok(Qr::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
})
}
} else if context.is_self_addr(&addr).await? {
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
Ok(Qr::WithdrawVerifyContact {
@@ -800,6 +876,12 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
invitenumber,
authcode,
..
}
| Qr::WithdrawJoinBroadcast {
grpid,
invitenumber,
authcode,
..
} => {
token::delete(context, &grpid).await?;
context
@@ -829,6 +911,12 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
authcode,
grpid,
..
}
| Qr::ReviveJoinBroadcast {
invitenumber,
authcode,
grpid,
..
} => {
let timestamp = time();
token::save(

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::chat::create_group;
use crate::chat::{Chat, create_broadcast, create_group, get_chat_contacts};
use crate::config::Config;
use crate::login_param::EnteredCertificateChecks;
use crate::provider::Socket;
@@ -511,6 +511,56 @@ async fn test_withdraw_verifygroup() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_joinbroadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = create_broadcast(alice, "foo".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(chat_id)).await?;
// scanning own verify-group code offers withdrawing
if let Qr::WithdrawJoinBroadcast { name, .. } = check_qr(alice, &qr).await? {
assert_eq!(name, "foo");
} else {
bail!("Wrong QR type, expected WithdrawJoinBroadcast");
}
set_config_from_qr(alice, &qr).await?;
// scanning withdrawn verify-group code offers reviving
if let Qr::ReviveJoinBroadcast { name, .. } = check_qr(alice, &qr).await? {
assert_eq!(name, "foo");
} else {
bail!("Wrong QR type, expected ReviveJoinBroadcast");
}
// someone else always scans as ask-verify-group
if let Qr::AskJoinBroadcast { name, .. } = check_qr(bob, &qr).await? {
assert_eq!(name, "foo");
} else {
bail!("Wrong QR type, expected AskJoinBroadcast");
}
assert!(set_config_from_qr(bob, &qr).await.is_err());
// Bob can't join using this QR code, since it's still withdrawn
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.is_self_in_chat(bob).await?, false);
assert_eq!(get_chat_contacts(alice, chat_id).await?.len(), 0);
// Revive
set_config_from_qr(alice, &qr).await?;
// Now Bob can join
let bob_chat_id2 = tcm.exec_securejoin_qr(bob, alice, &qr).await;
assert_eq!(bob_chat_id, bob_chat_id2);
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.is_self_in_chat(bob).await?, true);
assert_eq!(get_chat_contacts(alice, chat_id).await?.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_multidevice() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -454,7 +454,8 @@ impl Context {
let domain =
&deltachat_contact_tools::EmailAddress::new(&self.get_primary_self_addr().await?)?
.domain;
let storage_on_domain = stock_str::storage_on_domain(self, domain).await;
let storage_on_domain =
escaper::encode_minimal(&stock_str::storage_on_domain(self, domain).await);
ret += &format!("<h3>{storage_on_domain}</h3><ul>");
let quota = self.quota.read().await;
if let Some(quota) = &*quota {
@@ -528,11 +529,15 @@ impl Context {
}
}
} else {
ret += format!("<li>Warning: {domain} claims to support quota but gives no information</li>").as_str();
let domain_escaped = escaper::encode_minimal(domain);
ret += &format!(
"<li>Warning: {domain_escaped} claims to support quota but gives no information</li>"
);
}
}
Err(e) => {
ret += format!("<li>{e}</li>").as_str();
let error_escaped = escaper::encode_minimal(&e.to_string());
ret += &format!("<li>{error_escaped}</li>");
}
}
} else {

View File

@@ -302,12 +302,6 @@ pub enum StockMessage {
#[strum(props(fallback = "Message deletion timer is set to %1$s s by %2$s."))]
MsgEphemeralTimerEnabledBy = 141,
#[strum(props(fallback = "You set message deletion timer to 1 minute."))]
MsgYouEphemeralTimerMinute = 142,
#[strum(props(fallback = "Message deletion timer is set to 1 minute by %1$s."))]
MsgEphemeralTimerMinuteBy = 143,
#[strum(props(fallback = "You set message deletion timer to 1 hour."))]
MsgYouEphemeralTimerHour = 144,
@@ -1004,17 +998,6 @@ pub(crate) async fn msg_ephemeral_timer_enabled(
}
}
/// Stock string: `Message deletion timer is set to 1 minute.`.
pub(crate) async fn msg_ephemeral_timer_minute(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerMinute).await
} else {
translated(context, StockMessage::MsgEphemeralTimerMinuteBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
/// 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 {

View File

@@ -1679,7 +1679,6 @@ Until the false-positive is fixed:
}
}
#[cfg(test)]
mod tests {
use super::*;