mirror of
https://github.com/chatmail/core.git
synced 2026-05-18 06:16:29 +03:00
Compare commits
22 Commits
iroh11
...
iequidoo/1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67771735a9 | ||
|
|
0ea1e93567 | ||
|
|
83fa355291 | ||
|
|
25a78aceb9 | ||
|
|
66708454dd | ||
|
|
bb5e3d11d8 | ||
|
|
ff54db2e5f | ||
|
|
434d8fc35f | ||
|
|
12eb813bc3 | ||
|
|
88d5576150 | ||
|
|
af35e4adeb | ||
|
|
eaeacb8848 | ||
|
|
f00e68e142 | ||
|
|
113356a24e | ||
|
|
b89c134e7f | ||
|
|
ccca12176e | ||
|
|
c89dd331f7 | ||
|
|
fa81ed5f39 | ||
|
|
6c34f6b8d9 | ||
|
|
4f21a5691d | ||
|
|
b2a839971b | ||
|
|
8ad99be322 |
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -14,8 +14,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- main
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
@@ -83,9 +82,9 @@ jobs:
|
||||
- os: macos-latest
|
||||
rust: 1.73.0
|
||||
|
||||
# Minimum Supported Rust Version = 1.67.0
|
||||
# Minimum Supported Rust Version = 1.70.0
|
||||
- os: ubuntu-latest
|
||||
rust: 1.67.0
|
||||
rust: 1.70.0
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -167,8 +166,8 @@ jobs:
|
||||
working-directory: deltachat-rpc-client
|
||||
run: tox -e lint
|
||||
|
||||
python_tests:
|
||||
name: Python tests
|
||||
cffi_python_tests:
|
||||
name: CFFI Python tests
|
||||
needs: ["c_library", "python_lint"]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -218,8 +217,8 @@ jobs:
|
||||
working-directory: python
|
||||
run: tox -e mypy,doc,py
|
||||
|
||||
aysnc_python_tests:
|
||||
name: Async Python tests
|
||||
rpc_python_tests:
|
||||
name: JSON-RPC Python tests
|
||||
needs: ["python_lint", "rpc_server"]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
4
.github/workflows/jsonrpc.yml
vendored
4
.github/workflows/jsonrpc.yml
vendored
@@ -2,9 +2,9 @@ name: JSON-RPC API Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
2
.github/workflows/node-docs.yml
vendored
2
.github/workflows/node-docs.yml
vendored
@@ -8,7 +8,7 @@ name: Generate & upload node.js documentation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
|
||||
2
.github/workflows/node-tests.yml
vendored
2
.github/workflows/node-tests.yml
vendored
@@ -13,7 +13,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
3
.github/workflows/upload-docs.yml
vendored
3
.github/workflows/upload-docs.yml
vendored
@@ -3,8 +3,7 @@ name: Build & Deploy Documentation on rs.delta.chat
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- docs-gh-action
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
3
.github/workflows/upload-ffi-docs.yml
vendored
3
.github/workflows/upload-ffi-docs.yml
vendored
@@ -7,8 +7,7 @@ name: Build & Deploy Documentation on cffi.delta.chat
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- docs-gh-action
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
2775
Cargo.lock
generated
2775
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -3,7 +3,7 @@ name = "deltachat"
|
||||
version = "1.126.1"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.67"
|
||||
rust-version = "1.70"
|
||||
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
@@ -51,13 +51,12 @@ escaper = "0.1"
|
||||
fast-socks5 = "0.8"
|
||||
fd-lock = "3.0.11"
|
||||
futures = "0.3"
|
||||
futures-lite = "1.13.0"
|
||||
futures-lite = "2.0.0"
|
||||
hex = "0.4.0"
|
||||
hickory-resolver = "0.24"
|
||||
humansize = "2"
|
||||
image = { version = "0.24.7", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
|
||||
iroh04 = { package = "iroh", version = "0.4.1", default-features = false }
|
||||
iroh = "0.11"
|
||||
iroh = { version = "0.4.1", default-features = false }
|
||||
kamadak-exif = "0.5"
|
||||
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
|
||||
libc = "0.2"
|
||||
@@ -72,7 +71,7 @@ parking_lot = "0.12"
|
||||
pgp = { version = "0.10", default-features = false }
|
||||
pretty_env_logger = { version = "0.5", optional = true }
|
||||
qrcodegen = "1.7.0"
|
||||
quick-xml = "0.30"
|
||||
quick-xml = "0.31"
|
||||
rand = "0.8"
|
||||
regex = "1.9"
|
||||
reqwest = { version = "0.11.20", features = ["json"] }
|
||||
@@ -97,12 +96,11 @@ tokio-util = "0.7.9"
|
||||
toml = "0.7"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
quic-rpc = "0.6.1"
|
||||
|
||||
[dev-dependencies]
|
||||
ansi_term = "0.12.0"
|
||||
criterion = { version = "0.5.1", features = ["async_tokio"] }
|
||||
futures-lite = "1.13"
|
||||
futures-lite = "2.0.0"
|
||||
log = "0.4"
|
||||
pretty_env_logger = "0.5"
|
||||
proptest = { version = "1", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/yoav-lavi/melody/actions/workflows/rust.yml">
|
||||
<img alt="Rust CI" src="https://github.com/yoav-lavi/melody/actions/workflows/rust.yml/badge.svg">
|
||||
<a href="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml">
|
||||
<img alt="Rust CI" src="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml/badge.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ futures = { version = "0.3.28" }
|
||||
serde_json = "1.0.105"
|
||||
yerpc = { version = "0.5.2", features = ["anyhow_expose", "openrpc"] }
|
||||
typescript-type-def = { version = "0.5.8", features = ["json_value"] }
|
||||
tokio = { version = "1.32.0" }
|
||||
tokio = { version = "1.33.0" }
|
||||
sanitize-filename = "0.5"
|
||||
walkdir = "2.3.3"
|
||||
base64 = "0.21"
|
||||
@@ -34,7 +34,7 @@ axum = { version = "0.6.20", optional = true, features = ["ws"] }
|
||||
env_logger = { version = "0.10.0", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.32.0", features = ["full", "rt-multi-thread"] }
|
||||
tokio = { version = "1.33.0", features = ["full", "rt-multi-thread"] }
|
||||
|
||||
|
||||
[features]
|
||||
|
||||
@@ -15,11 +15,11 @@ deltachat = { path = "..", default-features = false }
|
||||
|
||||
anyhow = "1"
|
||||
env_logger = { version = "0.10.0" }
|
||||
futures-lite = "1.13.0"
|
||||
futures-lite = "2.0.0"
|
||||
log = "0.4"
|
||||
serde_json = "1.0.105"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1.32.0", features = ["io-std"] }
|
||||
tokio = { version = "1.33.0", features = ["io-std"] }
|
||||
tokio-util = "0.7.9"
|
||||
yerpc = { version = "0.5.2", features = ["anyhow_expose", "openrpc"] }
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ skip = [
|
||||
{ name = "digest", version = "<0.10" },
|
||||
{ name = "ed25519-dalek", version = "1.0.1" },
|
||||
{ name = "ed25519", version = "1.5.3" },
|
||||
{ name = "fastrand", version = "1.9.0" },
|
||||
{ name = "getrandom", version = "<0.2" },
|
||||
{ name = "hashbrown", version = "<0.14.0" },
|
||||
{ name = "indexmap", version = "<2.0.0" },
|
||||
|
||||
@@ -270,7 +270,7 @@ describe('Basic offline Tests', function () {
|
||||
'quota_exceeding',
|
||||
'scan_all_folders_debounce_secs',
|
||||
'selfavatar',
|
||||
'send_sync_msgs',
|
||||
'sync_msgs',
|
||||
'sentbox_watch',
|
||||
'show_emails',
|
||||
'socks5_enabled',
|
||||
|
||||
@@ -3,14 +3,14 @@ resources:
|
||||
type: git
|
||||
icon: github
|
||||
source:
|
||||
branch: master
|
||||
branch: main
|
||||
uri: https://github.com/deltachat/deltachat-core-rust.git
|
||||
|
||||
- name: deltachat-core-rust-release
|
||||
type: git
|
||||
icon: github
|
||||
source:
|
||||
branch: master
|
||||
branch: main
|
||||
uri: https://github.com/deltachat/deltachat-core-rust.git
|
||||
tag_filter: "v*"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::str::FromStr;
|
||||
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use strum::{EnumProperty, IntoEnumIterator};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumProperty, EnumString};
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::constants::DC_VERSION_STR;
|
||||
@@ -297,10 +297,9 @@ pub enum Config {
|
||||
#[strum(props(default = "0"))]
|
||||
DownloadLimit,
|
||||
|
||||
/// Send sync messages, requires `BccSelf` to be set as well.
|
||||
/// In a future versions, this switch may be removed.
|
||||
/// Enable sending and executing (applying) sync messages. Sending requires `BccSelf` to be set.
|
||||
#[strum(props(default = "0"))]
|
||||
SendSyncMsgs,
|
||||
SyncMsgs,
|
||||
|
||||
/// Space-separated list of all the authserv-ids which we believe
|
||||
/// may be the one of our email server.
|
||||
@@ -335,9 +334,6 @@ pub enum Config {
|
||||
/// until `chat_id.accept()` is called.
|
||||
#[strum(props(default = "0"))]
|
||||
VerifiedOneOnOneChats,
|
||||
|
||||
/// The iroh document ticket.
|
||||
DocTicket,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
@@ -504,7 +500,7 @@ impl Context {
|
||||
| Config::Configured
|
||||
| Config::Bot
|
||||
| Config::NotifyAboutWrongPw
|
||||
| Config::SendSyncMsgs
|
||||
| Config::SyncMsgs
|
||||
| Config::SignUnencrypted
|
||||
| Config::DisableIdle => {
|
||||
ensure!(
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::ffi::OsString;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
@@ -245,8 +244,6 @@ pub struct InnerContext {
|
||||
/// Standard RwLock instead of [`tokio::sync::RwLock`] is used
|
||||
/// because the lock is used from synchronous [`Context::emit_event`].
|
||||
pub(crate) debug_logging: std::sync::RwLock<Option<DebugLogging>>,
|
||||
|
||||
pub(crate) iroh_node: iroh::node::Node<iroh::bytes::store::flat::Store>,
|
||||
}
|
||||
|
||||
/// The state of ongoing process.
|
||||
@@ -315,10 +312,7 @@ impl Context {
|
||||
if !blobdir.exists() {
|
||||
tokio::fs::create_dir_all(&blobdir).await?;
|
||||
}
|
||||
let irohdir = dbfile.with_file_name("iroh");
|
||||
let context =
|
||||
Context::with_blobdir(dbfile.into(), blobdir, irohdir, id, events, stockstrings)
|
||||
.await?;
|
||||
let context = Context::with_blobdir(dbfile.into(), blobdir, id, events, stockstrings)?;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
@@ -355,10 +349,9 @@ impl Context {
|
||||
self.sql.check_passphrase(passphrase).await
|
||||
}
|
||||
|
||||
pub(crate) async fn with_blobdir(
|
||||
pub(crate) fn with_blobdir(
|
||||
dbfile: PathBuf,
|
||||
blobdir: PathBuf,
|
||||
irohdir: PathBuf,
|
||||
id: u32,
|
||||
events: Events,
|
||||
stockstrings: StockStrings,
|
||||
@@ -369,31 +362,6 @@ impl Context {
|
||||
blobdir.display()
|
||||
);
|
||||
|
||||
tokio::fs::create_dir_all(&irohdir).await?;
|
||||
let keyfile = irohdir.join("secret-key");
|
||||
let key = if tokio::fs::try_exists(&keyfile).await? {
|
||||
let s = tokio::fs::read_to_string(&keyfile).await?;
|
||||
iroh::net::key::SecretKey::from_str(&s)?
|
||||
} else {
|
||||
let key = iroh::net::key::SecretKey::generate();
|
||||
tokio::fs::write(keyfile, key.to_string()).await?;
|
||||
key
|
||||
};
|
||||
let rt = iroh::bytes::util::runtime::Handle::from_current(1)?;
|
||||
let baostore = iroh::bytes::store::flat::Store::load(
|
||||
irohdir.join("complete"),
|
||||
irohdir.join("partial"),
|
||||
irohdir.join("meta"),
|
||||
&rt,
|
||||
)
|
||||
.await?;
|
||||
let docstore = iroh::sync::store::fs::Store::new(irohdir.join("docs"))?;
|
||||
let iroh_node = iroh::node::Node::builder(baostore, docstore)
|
||||
.secret_key(key)
|
||||
.runtime(&rt)
|
||||
.spawn()
|
||||
.await?;
|
||||
|
||||
let new_msgs_notify = Notify::new();
|
||||
// Notify once immediately to allow processing old messages
|
||||
// without starting I/O.
|
||||
@@ -420,7 +388,6 @@ impl Context {
|
||||
last_full_folder_scan: Mutex::new(None),
|
||||
last_error: std::sync::RwLock::new("".to_string()),
|
||||
debug_logging: std::sync::RwLock::new(None),
|
||||
iroh_node,
|
||||
};
|
||||
|
||||
let ctx = Context {
|
||||
@@ -437,19 +404,6 @@ impl Context {
|
||||
return;
|
||||
}
|
||||
self.scheduler.start(self.clone()).await;
|
||||
|
||||
if let Ok(Some(ticket)) = self.get_config(Config::DocTicket).await {
|
||||
if let Err(err) = self.start_iroh(ticket).await {
|
||||
error!(self, "failed to join iroh doc, {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_iroh(&self, ticket: String) -> Result<()> {
|
||||
let client = self.iroh_node.client();
|
||||
let _doc = client.docs.import(ticket.parse()?).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops the IO scheduler.
|
||||
@@ -630,7 +584,7 @@ impl Context {
|
||||
let e2ee_enabled = self.get_config_int(Config::E2eeEnabled).await?;
|
||||
let mdns_enabled = self.get_config_int(Config::MdnsEnabled).await?;
|
||||
let bcc_self = self.get_config_int(Config::BccSelf).await?;
|
||||
let send_sync_msgs = self.get_config_int(Config::SendSyncMsgs).await?;
|
||||
let sync_msgs = self.get_config_int(Config::SyncMsgs).await?;
|
||||
let disable_idle = self.get_config_bool(Config::DisableIdle).await?;
|
||||
|
||||
let prv_key_cnt = self.sql.count("SELECT COUNT(*) FROM keypairs;", ()).await?;
|
||||
@@ -743,7 +697,7 @@ impl Context {
|
||||
self.get_config_int(Config::KeyGenType).await?.to_string(),
|
||||
);
|
||||
res.insert("bcc_self", bcc_self.to_string());
|
||||
res.insert("send_sync_msgs", send_sync_msgs.to_string());
|
||||
res.insert("sync_msgs", sync_msgs.to_string());
|
||||
res.insert("disable_idle", disable_idle.to_string());
|
||||
res.insert("private_key_count", prv_key_cnt.to_string());
|
||||
res.insert("public_key_count", pub_key_cnt.to_string());
|
||||
@@ -1288,16 +1242,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbfile = tmp.path().join("db.sqlite");
|
||||
let blobdir = PathBuf::new();
|
||||
let irohdir = tmp.path().join("iroh");
|
||||
let res = Context::with_blobdir(
|
||||
dbfile,
|
||||
blobdir,
|
||||
irohdir,
|
||||
1,
|
||||
Events::new(),
|
||||
StockStrings::new(),
|
||||
)
|
||||
.await;
|
||||
let res = Context::with_blobdir(dbfile, blobdir, 1, Events::new(), StockStrings::new());
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
@@ -1306,16 +1251,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbfile = tmp.path().join("db.sqlite");
|
||||
let blobdir = tmp.path().join("blobs");
|
||||
let irohdir = tmp.path().join("iroh");
|
||||
let res = Context::with_blobdir(
|
||||
dbfile,
|
||||
blobdir,
|
||||
irohdir,
|
||||
1,
|
||||
Events::new(),
|
||||
StockStrings::new(),
|
||||
)
|
||||
.await;
|
||||
let res = Context::with_blobdir(dbfile, blobdir, 1, Events::new(), StockStrings::new());
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
|
||||
90
src/imap.rs
90
src/imap.rs
@@ -1379,100 +1379,12 @@ impl Imap {
|
||||
Ok(msgs.into_iter().map(|((_, uid), msg)| (uid, msg)).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_many_msgs(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
folder: &str,
|
||||
request_uids: Vec<u32>,
|
||||
uid_message_ids: &BTreeMap<u32, String>,
|
||||
fetch_partially: bool,
|
||||
fetching_existing_messages: bool,
|
||||
) -> Result<(Option<u32>, Vec<ReceivedMsg>)> {
|
||||
let client = context.iroh_node.client();
|
||||
let n_docs = client.docs.list().await?.count().await;
|
||||
if n_docs == 1 {
|
||||
self.fetch_many_msgs_iroh(
|
||||
context,
|
||||
folder,
|
||||
request_uids,
|
||||
uid_message_ids,
|
||||
fetch_partially,
|
||||
fetching_existing_messages,
|
||||
client,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
self.fetch_many_msgs_imap(
|
||||
context,
|
||||
folder,
|
||||
request_uids,
|
||||
uid_message_ids,
|
||||
fetch_partially,
|
||||
fetching_existing_messages,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_many_msgs_iroh(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
_folder: &str,
|
||||
request_uids: Vec<u32>,
|
||||
uid_message_ids: &BTreeMap<u32, String>,
|
||||
_fetch_partially: bool,
|
||||
_fetching_existing_messages: bool,
|
||||
// client: iroh::client::quic::Iroh,
|
||||
client: iroh::client::Iroh<
|
||||
quic_rpc::transport::flume::FlumeConnection<
|
||||
iroh::rpc_protocol::ProviderResponse,
|
||||
iroh::rpc_protocol::ProviderRequest,
|
||||
>,
|
||||
>,
|
||||
) -> Result<(Option<u32>, Vec<ReceivedMsg>)> {
|
||||
let mut received_msgs = Vec::new();
|
||||
let mut last_uid = None;
|
||||
|
||||
let (id, _caps) = client.docs.list().await?.next().await.context("no doc")??;
|
||||
let doc = client.docs.open(id).await?.context("where is my doc?")?;
|
||||
|
||||
for request_uid in request_uids {
|
||||
let rfc724_mid = if let Some(rfc724_mid) = uid_message_ids.get(&request_uid) {
|
||||
rfc724_mid
|
||||
} else {
|
||||
error!(
|
||||
context,
|
||||
"No Message-ID corresponding to UID {} passed in uid_messsage_ids.",
|
||||
request_uid
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let key = format!("/inbox/{rfc724_mid}");
|
||||
warn!(context, "Reading iroh key {key}");
|
||||
let query = iroh::sync::store::Query::key_exact(&key).build();
|
||||
let entry = doc.get_one(query).await?.context("entry not found")?;
|
||||
let imf_raw = doc.read_to_bytes(&entry).await?;
|
||||
info!(
|
||||
context,
|
||||
"Passing message UID {} to receive_imf().", request_uid
|
||||
);
|
||||
match receive_imf_inner(context, rfc724_mid, &imf_raw, false, None, false).await {
|
||||
Ok(Some(msg)) => received_msgs.push(msg),
|
||||
Ok(None) => (),
|
||||
Err(err) => warn!(context, "receive_imf error: {err:#}"),
|
||||
}
|
||||
|
||||
last_uid = Some(request_uid);
|
||||
}
|
||||
Ok((last_uid, received_msgs))
|
||||
}
|
||||
|
||||
/// Fetches a list of messages by server UID.
|
||||
///
|
||||
/// Returns the last UID fetched successfully and the info about each downloaded message.
|
||||
/// If the message is incorrect or there is a failure to write a message to the database,
|
||||
/// it is skipped and the error is logged.
|
||||
pub(crate) async fn fetch_many_msgs_imap(
|
||||
pub(crate) async fn fetch_many_msgs(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
folder: &str,
|
||||
|
||||
@@ -32,12 +32,12 @@ use std::task::Poll;
|
||||
use anyhow::{anyhow, bail, ensure, format_err, Context as _, Result};
|
||||
use async_channel::Receiver;
|
||||
use futures_lite::StreamExt;
|
||||
use iroh04::blobs::Collection;
|
||||
use iroh04::get::DataStream;
|
||||
use iroh04::progress::ProgressEmitter;
|
||||
use iroh04::protocol::AuthToken;
|
||||
use iroh04::provider::{DataSource, Event, Provider, Ticket};
|
||||
use iroh04::Hash;
|
||||
use iroh::blobs::Collection;
|
||||
use iroh::get::DataStream;
|
||||
use iroh::progress::ProgressEmitter;
|
||||
use iroh::protocol::AuthToken;
|
||||
use iroh::provider::{DataSource, Event, Provider, Ticket};
|
||||
use iroh::Hash;
|
||||
use tokio::fs::{self, File};
|
||||
use tokio::io::{self, AsyncWriteExt, BufWriter};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
@@ -176,7 +176,7 @@ impl BackupProvider {
|
||||
}
|
||||
|
||||
// Start listening.
|
||||
let (db, hash) = iroh04::provider::create_collection(files).await?;
|
||||
let (db, hash) = iroh::provider::create_collection(files).await?;
|
||||
context.emit_event(SendProgress::CollectionCreated.into());
|
||||
let provider = Provider::builder(db)
|
||||
.bind_addr((Ipv4Addr::UNSPECIFIED, 0).into())
|
||||
@@ -382,7 +382,7 @@ impl From<SendProgress> for EventType {
|
||||
/// This is a long running operation which will only when completed.
|
||||
///
|
||||
/// Using [`Qr`] as argument is a bit odd as it only accepts one specific variant of it. It
|
||||
/// does avoid having [`iroh04::provider::Ticket`] in the primary API however, without
|
||||
/// does avoid having [`iroh::provider::Ticket`] in the primary API however, without
|
||||
/// having to revert to untyped bytes.
|
||||
pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
|
||||
ensure!(
|
||||
@@ -456,7 +456,7 @@ async fn transfer_from_provider(context: &Context, ticket: &Ticket) -> Result<()
|
||||
|
||||
// Perform the transfer.
|
||||
let keylog = false; // Do not enable rustls SSLKEYLOGFILE env var functionality
|
||||
let stats = iroh04::get::run_ticket(
|
||||
let stats = iroh::get::run_ticket(
|
||||
ticket,
|
||||
keylog,
|
||||
MAX_CONCURRENT_DIALS,
|
||||
|
||||
@@ -1208,6 +1208,9 @@ impl MimeMessage {
|
||||
}
|
||||
msg_type
|
||||
} else if filename == "multi-device-sync.json" {
|
||||
if !context.get_config_bool(Config::SyncMsgs).await? {
|
||||
return Ok(());
|
||||
}
|
||||
let serialized = String::from_utf8_lossy(decoded_data)
|
||||
.parse()
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -114,7 +114,7 @@ pub enum Qr {
|
||||
/// information to connect to and authenticate a backup provider.
|
||||
///
|
||||
/// The format is somewhat opaque, but `sendme` can deserialise this.
|
||||
ticket: iroh04::provider::Ticket,
|
||||
ticket: iroh::provider::Ticket,
|
||||
},
|
||||
|
||||
/// Ask the user if they want to use the given service for video chats.
|
||||
@@ -497,12 +497,12 @@ fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result<Qr> {
|
||||
/// Decodes a [`DCBACKUP_SCHEME`] QR code.
|
||||
///
|
||||
/// The format of this scheme is `DCBACKUP:<encoded ticket>`. The encoding is the
|
||||
/// [`iroh04::provider::Ticket`]'s `Display` impl.
|
||||
/// [`iroh::provider::Ticket`]'s `Display` impl.
|
||||
fn decode_backup(qr: &str) -> Result<Qr> {
|
||||
let payload = qr
|
||||
.strip_prefix(DCBACKUP_SCHEME)
|
||||
.ok_or_else(|| anyhow!("invalid DCBACKUP scheme"))?;
|
||||
let ticket: iroh04::provider::Ticket = payload.parse().context("invalid DCBACKUP payload")?;
|
||||
let ticket: iroh::provider::Ticket = payload.parse().context("invalid DCBACKUP payload")?;
|
||||
Ok(Qr::Backup { ticket })
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::ephemeral::{stock_ephemeral_timer_changed, Timer as EphemeralTimer};
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::imap::{markseen_on_imap_table, GENERATED_PREFIX};
|
||||
use crate::key::DcKey;
|
||||
use crate::location;
|
||||
use crate::log::LogExt;
|
||||
use crate::message::{
|
||||
@@ -601,11 +602,7 @@ async fn add_parts(
|
||||
context,
|
||||
mime_parser,
|
||||
is_partial_download.is_some(),
|
||||
if test_normal_chat.is_none() {
|
||||
allow_creation
|
||||
} else {
|
||||
true
|
||||
},
|
||||
test_normal_chat.is_some() || allow_creation,
|
||||
create_blocked,
|
||||
from_id,
|
||||
to_ids,
|
||||
@@ -617,6 +614,7 @@ async fn add_parts(
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
// if the chat is somehow blocked but we want to create a non-blocked chat,
|
||||
// unblock the chat
|
||||
if chat_id_blocked != Blocked::Not && create_blocked != Blocked::Yes {
|
||||
@@ -705,7 +703,7 @@ async fn add_parts(
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(chat) = test_normal_chat {
|
||||
if let Some(chat) = test_normal_chat.as_ref() {
|
||||
chat_id = Some(chat.id);
|
||||
chat_id_blocked = chat.blocked;
|
||||
} else if allow_creation {
|
||||
@@ -719,7 +717,8 @@ async fn add_parts(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(chat_id) = chat_id {
|
||||
let chat_id_ref = &mut chat_id;
|
||||
if let Some(chat_id) = *chat_id_ref {
|
||||
if chat_id_blocked != Blocked::Not {
|
||||
if chat_id_blocked != create_blocked {
|
||||
chat_id.set_blocked(context, create_blocked).await?;
|
||||
@@ -766,7 +765,44 @@ async fn add_parts(
|
||||
// That's why the config is checked here, and not above.
|
||||
&& context.get_config_bool(Config::VerifiedOneOnOneChats).await?
|
||||
{
|
||||
new_protection = ProtectionStatus::ProtectionBroken;
|
||||
let decryption_info = &mime_parser.decryption_info;
|
||||
new_protection =
|
||||
match decryption_info.autocrypt_header.as_ref().filter(|ah| {
|
||||
Some(&ah.public_key.fingerprint())
|
||||
!= decryption_info
|
||||
.peerstate
|
||||
.as_ref()
|
||||
.and_then(|p| p.verified_key_fingerprint.as_ref())
|
||||
}) {
|
||||
None => {
|
||||
if let Some(new_chat_id) = create_adhoc_group(
|
||||
context,
|
||||
mime_parser,
|
||||
create_blocked,
|
||||
&[ContactId::SELF, from_id],
|
||||
)
|
||||
.await
|
||||
.context("could not create ad hoc group")?
|
||||
{
|
||||
info!(
|
||||
context,
|
||||
"Moving message to a new ad-hoc group keeping 1:1 chat \
|
||||
protection.",
|
||||
);
|
||||
*chat_id_ref = Some(new_chat_id);
|
||||
chat_id_blocked = create_blocked;
|
||||
continue;
|
||||
} else {
|
||||
warn!(
|
||||
context,
|
||||
"Rejected to create an ad-hoc group, but keeping 1:1 chat \
|
||||
protection.",
|
||||
);
|
||||
}
|
||||
chat.protected
|
||||
}
|
||||
Some(_) => ProtectionStatus::ProtectionBroken,
|
||||
};
|
||||
}
|
||||
if chat.protected != new_protection {
|
||||
// The message itself will be sorted under the device message since the device
|
||||
@@ -795,6 +831,8 @@ async fn add_parts(
|
||||
} else {
|
||||
MessageState::InFresh
|
||||
};
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Outgoing
|
||||
|
||||
@@ -1481,7 +1519,16 @@ async fn lookup_chat_by_reply(
|
||||
|
||||
// If this was a private message just to self, it was probably a private reply.
|
||||
// It should not go into the group then, but into the private chat.
|
||||
if is_probably_private_reply(context, to_ids, from_id, mime_parser, parent_chat.id).await? {
|
||||
if is_probably_private_reply(
|
||||
context,
|
||||
to_ids,
|
||||
from_id,
|
||||
mime_parser,
|
||||
parent_chat.id,
|
||||
&parent_chat.grpid,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -1511,13 +1558,14 @@ async fn is_probably_private_reply(
|
||||
from_id: ContactId,
|
||||
mime_parser: &MimeMessage,
|
||||
parent_chat_id: ChatId,
|
||||
parent_chat_grpid: &str,
|
||||
) -> Result<bool> {
|
||||
// Usually we don't want to show private replies in the parent chat, but in the
|
||||
// 1:1 chat with the sender.
|
||||
//
|
||||
// There is one exception: Classical MUA replies to two-member groups
|
||||
// should be assigned to the group chat. We restrict this exception to classical emails, as chat-group-messages
|
||||
// contain a Chat-Group-Id header and can be sorted into the correct chat this way.
|
||||
// An exception is replies to 2-member groups from classical MUAs or to 2-member ad-hoc
|
||||
// groups. Such messages can't contain a Chat-Group-Id header and need to be sorted purely by
|
||||
// References/In-Reply-To.
|
||||
|
||||
let private_message =
|
||||
(to_ids == [ContactId::SELF]) || (from_id == ContactId::SELF && to_ids.len() == 1);
|
||||
@@ -1525,7 +1573,7 @@ async fn is_probably_private_reply(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if !mime_parser.has_chat_version() {
|
||||
if !mime_parser.has_chat_version() || parent_chat_grpid.is_empty() {
|
||||
let chat_contacts = chat::get_chat_contacts(context, parent_chat_id).await?;
|
||||
if chat_contacts.len() == 2 && chat_contacts.contains(&ContactId::SELF) {
|
||||
return Ok(false);
|
||||
@@ -1560,6 +1608,10 @@ async fn create_or_lookup_group(
|
||||
member_ids.push(ContactId::SELF);
|
||||
}
|
||||
|
||||
if member_ids.len() < 3 {
|
||||
info!(context, "Not creating ad-hoc group: too few contacts.");
|
||||
return Ok(None);
|
||||
}
|
||||
let res = create_adhoc_group(context, mime_parser, create_blocked, &member_ids)
|
||||
.await
|
||||
.context("could not create ad hoc group")?
|
||||
@@ -1584,7 +1636,8 @@ async fn create_or_lookup_group(
|
||||
// they belong to the group because of the Chat-Group-Id or Message-Id header
|
||||
if let Some(chat_id) = chat_id {
|
||||
if !mime_parser.has_chat_version()
|
||||
&& is_probably_private_reply(context, to_ids, from_id, mime_parser, chat_id).await?
|
||||
&& is_probably_private_reply(context, to_ids, from_id, mime_parser, chat_id, &grpid)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -2213,11 +2266,6 @@ async fn create_adhoc_group(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if member_ids.len() < 3 {
|
||||
info!(context, "Not creating ad-hoc group: too few contacts.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// use subject as initial chat name
|
||||
let grpname = mime_parser
|
||||
.get_subject()
|
||||
|
||||
30
src/sync.rs
30
src/sync.rs
@@ -43,13 +43,6 @@ pub(crate) struct SyncItems {
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Checks if sync messages shall be sent.
|
||||
/// Receiving sync messages is currently always enabled;
|
||||
/// the messages are force-encrypted anyway.
|
||||
async fn is_sync_sending_enabled(&self) -> Result<bool> {
|
||||
self.get_config_bool(Config::SendSyncMsgs).await
|
||||
}
|
||||
|
||||
/// Adds an item to the list of items that should be synchronized to other devices.
|
||||
pub(crate) async fn add_sync_item(&self, data: SyncData) -> Result<()> {
|
||||
self.add_sync_item_with_timestamp(data, time()).await
|
||||
@@ -58,7 +51,7 @@ impl Context {
|
||||
/// Adds item and timestamp to the list of items that should be synchronized to other devices.
|
||||
/// If device synchronization is disabled, the function does nothing.
|
||||
async fn add_sync_item_with_timestamp(&self, data: SyncData, timestamp: i64) -> Result<()> {
|
||||
if !self.is_sync_sending_enabled().await? {
|
||||
if !self.get_config_bool(Config::SyncMsgs).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -75,7 +68,7 @@ impl Context {
|
||||
/// If device synchronization is disabled,
|
||||
/// no tokens exist or the chat is unpromoted, the function does nothing.
|
||||
pub(crate) async fn sync_qr_code_tokens(&self, chat_id: Option<ChatId>) -> Result<()> {
|
||||
if !self.is_sync_sending_enabled().await? {
|
||||
if !self.get_config_bool(Config::SyncMsgs).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -267,20 +260,20 @@ mod tests {
|
||||
use crate::token::Namespace;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_is_sync_sending_enabled() -> Result<()> {
|
||||
async fn test_config_sync_msgs() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
assert!(!t.is_sync_sending_enabled().await?);
|
||||
t.set_config_bool(Config::SendSyncMsgs, true).await?;
|
||||
assert!(t.is_sync_sending_enabled().await?);
|
||||
t.set_config_bool(Config::SendSyncMsgs, false).await?;
|
||||
assert!(!t.is_sync_sending_enabled().await?);
|
||||
assert!(!t.get_config_bool(Config::SyncMsgs).await?);
|
||||
t.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
assert!(t.get_config_bool(Config::SyncMsgs).await?);
|
||||
t.set_config_bool(Config::SyncMsgs, false).await?;
|
||||
assert!(!t.get_config_bool(Config::SyncMsgs).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_build_sync_json() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
t.set_config_bool(Config::SendSyncMsgs, true).await?;
|
||||
t.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
assert!(t.build_sync_json().await?.is_none());
|
||||
|
||||
@@ -325,7 +318,7 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_build_sync_json_sync_msgs_off() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
t.set_config_bool(Config::SendSyncMsgs, false).await?;
|
||||
t.set_config_bool(Config::SyncMsgs, false).await?;
|
||||
t.add_sync_item(SyncData::AddQrToken(QrTokenData {
|
||||
invitenumber: "testinvite".to_string(),
|
||||
auth: "testauth".to_string(),
|
||||
@@ -453,7 +446,7 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_send_sync_msg() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
alice.set_config_bool(Config::SendSyncMsgs, true).await?;
|
||||
alice.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
alice
|
||||
.add_sync_item(SyncData::AddQrToken(QrTokenData {
|
||||
invitenumber: "in".to_string(),
|
||||
@@ -480,6 +473,7 @@ mod tests {
|
||||
// also here, self-talk should stay hidden
|
||||
let sent_msg = alice.pop_sent_msg().await;
|
||||
let alice2 = TestContext::new_alice().await;
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
alice2.recv_msg(&sent_msg).await;
|
||||
assert!(token::exists(&alice2, token::Namespace::Auth, "testtoken").await);
|
||||
assert_eq!(Chatlist::try_load(&alice2, 0, None, None).await?.len(), 0);
|
||||
|
||||
@@ -4,7 +4,7 @@ use pretty_assertions::assert_eq;
|
||||
use crate::chat::{Chat, ProtectionStatus};
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::config::Config;
|
||||
use crate::constants::DC_GCL_FOR_FORWARDING;
|
||||
use crate::constants::{Chattype, DC_GCL_FOR_FORWARDING};
|
||||
use crate::contact::VerifiedStatus;
|
||||
use crate::contact::{Contact, Origin};
|
||||
use crate::message::{Message, Viewtype};
|
||||
@@ -12,7 +12,9 @@ use crate::mimefactory::MimeFactory;
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::stock_str;
|
||||
use crate::test_utils::{get_chat_msg, mark_as_verified, TestContext, TestContextManager};
|
||||
use crate::test_utils::{
|
||||
get_chat_msg, mark_as_verified, TestContext, TestContextManager,
|
||||
};
|
||||
use crate::{e2ee, message};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -742,6 +744,50 @@ async fn test_create_oneonone_chat_with_former_verified_contact() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Some messages are sent unencrypted, but they mustn't break a verified chat protection.
|
||||
/// They must go to a new 2-member ad-hoc group instead.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_unencrypted() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
enable_verified_oneonone_chats(&[&alice]).await;
|
||||
mark_as_verified(&alice, &bob).await;
|
||||
alice.create_chat(&bob).await;
|
||||
|
||||
let msg = tcm.send_recv(&bob, &alice, "hi").await;
|
||||
assert!(!msg.get_showpadlock());
|
||||
let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?;
|
||||
assert_eq!(alice_chat.typ, Chattype::Group);
|
||||
|
||||
// The next unencrypted message must get to the same ad-hoc group thanks to "In-Reply-To".
|
||||
let msg = tcm.send_recv(&bob, &alice, "hi again").await;
|
||||
assert!(!msg.get_showpadlock());
|
||||
assert_eq!(msg.chat_id, alice_chat.id);
|
||||
let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?;
|
||||
assert_eq!(alice_chat.typ, Chattype::Group);
|
||||
|
||||
// This message is missed by Alice.
|
||||
let chat_id = bob.get_chat(&alice).await.id;
|
||||
bob.send_text(chat_id, "hi to the void").await;
|
||||
|
||||
// But the next message must get to the same ad-hoc group thanks to "References".
|
||||
let msg = tcm.send_recv(&bob, &alice, "hi in a new group").await;
|
||||
assert!(!msg.get_showpadlock());
|
||||
assert_eq!(msg.chat_id, alice_chat.id);
|
||||
let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?;
|
||||
assert_eq!(alice_chat.typ, Chattype::Group);
|
||||
|
||||
let alice_chat = alice.get_chat(&bob).await;
|
||||
assert!(alice_chat.is_protected());
|
||||
assert!(!alice_chat.is_protection_broken());
|
||||
alice
|
||||
.golden_test_chat(alice_chat.id, "verified_chats_test_unencrypted")
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============== Helper Functions ==============
|
||||
|
||||
async fn assert_verified(this: &TestContext, other: &TestContext, protected: ProtectionStatus) {
|
||||
|
||||
16
src/tools.rs
16
src/tools.rs
@@ -724,18 +724,18 @@ mod tests {
|
||||
|
||||
let raw = include_bytes!("../test-data/message/wrong-html.eml");
|
||||
let expected =
|
||||
"Hop: From: oxbsltgw18.schlund.de; By: mrelayeu.kundenserver.de; Date: Thu, 06 Aug 2020 16:40:31 +0000\n\
|
||||
Hop: From: mout.kundenserver.de; By: dd37930.kasserver.com; Date: Thu, 06 Aug 2020 16:40:32 +0000";
|
||||
"Hop: From: oxbsltgw18.schlund.de; By: mrelayeu.kundenserver.de; Date: Thu, 6 Aug 2020 16:40:31 +0000\n\
|
||||
Hop: From: mout.kundenserver.de; By: dd37930.kasserver.com; Date: Thu, 6 Aug 2020 16:40:32 +0000";
|
||||
check_parse_receive_headers(raw, expected);
|
||||
|
||||
let raw = include_bytes!("../test-data/message/posteo_ndn.eml");
|
||||
let expected =
|
||||
"Hop: By: mout01.posteo.de; Date: Tue, 09 Jun 2020 18:44:22 +0000\n\
|
||||
Hop: From: mout01.posteo.de; By: mx04.posteo.de; Date: Tue, 09 Jun 2020 18:44:22 +0000\n\
|
||||
Hop: From: mx04.posteo.de; By: mailin06.posteo.de; Date: Tue, 09 Jun 2020 18:44:23 +0000\n\
|
||||
Hop: From: mailin06.posteo.de; By: proxy02.posteo.de; Date: Tue, 09 Jun 2020 18:44:23 +0000\n\
|
||||
Hop: From: proxy02.posteo.de; By: proxy02.posteo.name; Date: Tue, 09 Jun 2020 18:44:23 +0000\n\
|
||||
Hop: From: proxy02.posteo.name; By: dovecot03.posteo.local; Date: Tue, 09 Jun 2020 18:44:24 +0000";
|
||||
"Hop: By: mout01.posteo.de; Date: Tue, 9 Jun 2020 18:44:22 +0000\n\
|
||||
Hop: From: mout01.posteo.de; By: mx04.posteo.de; Date: Tue, 9 Jun 2020 18:44:22 +0000\n\
|
||||
Hop: From: mx04.posteo.de; By: mailin06.posteo.de; Date: Tue, 9 Jun 2020 18:44:23 +0000\n\
|
||||
Hop: From: mailin06.posteo.de; By: proxy02.posteo.de; Date: Tue, 9 Jun 2020 18:44:23 +0000\n\
|
||||
Hop: From: proxy02.posteo.de; By: proxy02.posteo.name; Date: Tue, 9 Jun 2020 18:44:23 +0000\n\
|
||||
Hop: From: proxy02.posteo.name; By: dovecot03.posteo.local; Date: Tue, 9 Jun 2020 18:44:24 +0000";
|
||||
check_parse_receive_headers(raw, expected);
|
||||
}
|
||||
|
||||
|
||||
4
test-data/golden/verified_chats_test_unencrypted
Normal file
4
test-data/golden/verified_chats_test_unencrypted
Normal file
@@ -0,0 +1,4 @@
|
||||
Single#Chat#10: bob@example.net [bob@example.net] 🛡️
|
||||
--------------------------------------------------------------------------------
|
||||
Msg#10: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO 🛡️]
|
||||
--------------------------------------------------------------------------------
|
||||
Reference in New Issue
Block a user