Compare commits

..

3 Commits

Author SHA1 Message Date
link2xt
37c771d342 temporarily replace logging with print 2024-05-25 05:52:07 +00:00
Septias
72df9504ad optimize logs 2024-05-25 05:52:07 +00:00
Sebastian Klähn
c68008aa04 feat(peer_channels): call update_endpoints 2024-05-25 05:52:07 +00:00
37 changed files with 357 additions and 748 deletions

View File

@@ -125,6 +125,9 @@ jobs:
- name: Tests
env:
RUST_BACKTRACE: 1
# Workaround for <https://github.com/nextest-rs/nextest/issues/1493>.
RUSTUP_WINDOWS_PATH_ADD_BIN: 1
run: cargo nextest run --workspace
- name: Doc-Tests
@@ -209,11 +212,11 @@ jobs:
fail-fast: false
matrix:
include:
# Currently used Python version.
# Currently used Rust version.
- os: ubuntu-latest
python: 3.13
python: 3.12
- os: macos-latest
python: 3.13
python: 3.12
# PyPy tests
- os: ubuntu-latest
@@ -243,7 +246,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
allow-prereleases: true
- name: Install tox
run: pip install tox
@@ -264,11 +266,11 @@ jobs:
matrix:
include:
- os: ubuntu-latest
python: 3.13
python: 3.12
- os: macos-latest
python: 3.13
python: 3.12
- os: windows-latest
python: 3.13
python: 3.12
# PyPy tests
- os: ubuntu-latest
@@ -290,7 +292,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
allow-prereleases: true
- name: Install tox
run: pip install tox

View File

@@ -1,74 +1,5 @@
# Changelog
## [1.140.0] - 2024-06-04
### Features / Changes
- Remove limit on number of email recipients for chatmail clients ([#5598](https://github.com/deltachat/deltachat-core-rust/pull/5598)).
- Add config option to enable iroh ([#5607](https://github.com/deltachat/deltachat-core-rust/pull/5607)).
- Map `*.wav` to Viewtype::Audio ([#5633](https://github.com/deltachat/deltachat-core-rust/pull/5633)).
- Add a db index for reactions by msg_id ([#5507](https://github.com/deltachat/deltachat-core-rust/pull/5507)).
### Fixes
- Set Param::Bot for messages on the sender side as well ([#5615](https://github.com/deltachat/deltachat-core-rust/pull/5615)).
- AEAP: Remove old peerstate verified_key instead of removing the whole peerstate ([#5535](https://github.com/deltachat/deltachat-core-rust/pull/5535)).
- Allow creation of groups by outgoing messages without recipients.
- Prefer `Chat-Group-ID` over references for new groups.
- Do not fail to send images with wrong extensions.
### Build system
- Unpin OpenSSL version and update to OpenSSL 3.3.0.
### CI
- Remove cargo-nextest bug workaround.
### Documentation
- Add vCard as supported standard.
- Create_group() does not find chats, only creates them.
- Fix a typo in test_partial_group_consistency().
### Refactor
- Factor create_adhoc_group() call out of create_group().
- Put duplicate code into `lookup_chat_or_create_adhoc_group`.
### Tests
- Fix logging of TestContext created using TestContext::new_alice().
- Refactor `test_alias_*` into 8 separate tests.
## [1.139.6] - 2024-05-25
### Build system
- Update `iroh` to the git version.
- nix: Add iroh-base output hash.
- Upgrade iroh to 0.17.0.
### Fixes
- @deltachat/stdio-rpc-server: Do not set RUST_LOG to "info" by default.
- Acquire write lock on iroh_channels before checking for subscribe_loop.
### Miscellaneous Tasks
- Fix python lint.
- cargo-deny: Remove unused entry from deny.toml.
### Refactor
- Log IMAP connection type on connection failure.
### Tests
- Viewtype::File attachments are sent unchanged and preserve extensions.
- deltachat-rpc-client: Add realtime channel tests.
- deltachat-rpc-client: Regression test for double gossip subscription.
## [1.139.5] - 2024-05-23
### API-Changes
@@ -4369,5 +4300,3 @@ https://github.com/deltachat/deltachat-core-rust/pulls?q=is%3Apr+is%3Aclosed
[1.139.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.2...v1.139.3
[1.139.4]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.3...v1.139.4
[1.139.5]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.4...v1.139.5
[1.139.6]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.5...v1.139.6
[1.140.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.6...v1.140.0

71
Cargo.lock generated
View File

@@ -1324,7 +1324,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "1.140.0"
version = "1.139.5"
dependencies = [
"ansi_term",
"anyhow",
@@ -1367,6 +1367,7 @@ dependencies = [
"num-traits",
"num_cpus",
"once_cell",
"openssl-src",
"parking_lot",
"percent-encoding",
"pgp",
@@ -1418,7 +1419,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "1.140.0"
version = "1.139.5"
dependencies = [
"anyhow",
"async-channel 2.2.1",
@@ -1443,7 +1444,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "1.140.0"
version = "1.139.5"
dependencies = [
"ansi_term",
"anyhow",
@@ -1458,7 +1459,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "1.140.0"
version = "1.139.5"
dependencies = [
"anyhow",
"deltachat",
@@ -1487,7 +1488,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "1.140.0"
version = "1.139.5"
dependencies = [
"anyhow",
"deltachat",
@@ -3236,9 +3237,8 @@ dependencies = [
[[package]]
name = "iroh-base"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1be0b442ed44d20905cf77c673169906c883e05c829e3fb303b131e925139fc"
version = "0.16.0"
source = "git+https://github.com/n0-computer/iroh?rev=c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e#c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e"
dependencies = [
"aead",
"anyhow",
@@ -3277,9 +3277,8 @@ dependencies = [
[[package]]
name = "iroh-gossip"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857aa77a0b29283edf99224bd12cd739684c5873e639b85adb38b8d1d777162d"
version = "0.16.0"
source = "git+https://github.com/n0-computer/iroh?rev=c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e#c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e"
dependencies = [
"anyhow",
"bytes",
@@ -3303,9 +3302,8 @@ dependencies = [
[[package]]
name = "iroh-metrics"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd54b9cf342b2618efc8d3ff6cdcd083fa5a2cf6cc78bb473bd32e228eabb40e"
version = "0.16.0"
source = "git+https://github.com/n0-computer/iroh?rev=c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e#c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e"
dependencies = [
"anyhow",
"erased_set",
@@ -3324,9 +3322,8 @@ dependencies = [
[[package]]
name = "iroh-net"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a744000e6c5704479eeb4751eb23b9b8e7e56a0fc484beb8831694fc93e378f"
version = "0.16.0"
source = "git+https://github.com/n0-computer/iroh?rev=c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e#c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e"
dependencies = [
"aead",
"anyhow",
@@ -3371,7 +3368,7 @@ dependencies = [
"postcard",
"rand 0.8.5",
"rand_core 0.6.4",
"rcgen 0.12.1",
"rcgen 0.11.3",
"reqwest 0.12.4",
"ring 0.17.8",
"rtnetlink",
@@ -3401,9 +3398,9 @@ dependencies = [
[[package]]
name = "iroh-quinn"
version = "0.10.5"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "906875956feb75d3d41d708ddaffeb11fdb10cd05f23efbcb17600037e411779"
checksum = "b934380145fd5d53a583d01ae9500f4807efe6b0f0fe115c7be4afa2b35db99f"
dependencies = [
"bytes",
"iroh-quinn-proto",
@@ -3418,13 +3415,13 @@ dependencies = [
[[package]]
name = "iroh-quinn-proto"
version = "0.10.8"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6bf92478805e67f2320459285496e1137edf5171411001a0d4d85f9bbafb792"
checksum = "16f2656b322c7f6cf3eb95e632d1c0f2fa546841915b0270da581f918c70c4be"
dependencies = [
"bytes",
"rand 0.8.5",
"ring 0.17.8",
"ring 0.16.20",
"rustc-hash",
"rustls 0.21.11",
"rustls-native-certs",
@@ -3436,9 +3433,9 @@ dependencies = [
[[package]]
name = "iroh-quinn-udp"
version = "0.4.2"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc7915b3a31f08ee0bc02f73f4d61a5d5be146a1081ef7f70622a11627fd314"
checksum = "6679979a7271c24f9dae9622c0b4a543881508aa3a7396f55dfbaaa56f01c063"
dependencies = [
"bytes",
"libc",
@@ -4092,9 +4089,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.64"
version = "0.10.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
@@ -4124,18 +4121,18 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "300.3.0+3.3.0"
version = "300.1.6+3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba8804a1c5765b18c4b3f907e6897ebabeedebc9830e1a0046c4a4cf44663e1"
checksum = "439fac53e092cd7442a3660c85dde4643ab3b5bd39040912388dcdabf6b88085"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.102"
version = "0.9.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2"
checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae"
dependencies = [
"cc",
"libc",
@@ -5059,6 +5056,18 @@ dependencies = [
"yasna",
]
[[package]]
name = "rcgen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6"
dependencies = [
"pem 3.0.4",
"ring 0.16.20",
"time 0.3.34",
"yasna",
]
[[package]]
name = "rcgen"
version = "0.12.1"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "1.140.0"
version = "1.139.5"
edition = "2021"
license = "MPL-2.0"
rust-version = "1.77"
@@ -61,8 +61,8 @@ hickory-resolver = "0.24"
humansize = "2"
image = { version = "0.25.1", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh_old = { version = "0.4.2", default-features = false, package = "iroh"}
iroh-net = "0.17.0"
iroh-gossip = { version = "0.17.0", features = ["net"] }
iroh-net = { git = "https://github.com/n0-computer/iroh", rev="c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e" }
iroh-gossip = { git = "https://github.com/n0-computer/iroh", rev="c8690a2c6eb5753c4ec6b7e44db72abf09df3c6e", features = ["net"] }
quinn = "0.10.0"
kamadak-exif = "0.5.3"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
@@ -104,6 +104,14 @@ toml = "0.8"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
# Pin OpenSSL to 3.1 releases.
# OpenSSL 3.2 has a regression tracked at <https://github.com/openssl/openssl/issues/23376>
# which results in broken `deltachat-rpc-server` binaries when cross-compiled using Zig toolchain.
# See <https://github.com/deltachat/deltachat-core-rust/issues/5206> for Delta Chat issue.
# According to <https://www.openssl.org/policies/releasestrat.html>
# 3.1 branch will be supported until 2025-03-14.
openssl-src = "~300.1"
[dev-dependencies]
ansi_term = "0.12.0"
anyhow = { version = "1", features = ["backtrace"] } # Enable `backtrace` feature in tests.
@@ -171,4 +179,4 @@ vendored = [
"async-native-tls/vendored",
"rusqlite/bundled-sqlcipher-vendored-openssl",
"reqwest/native-tls-vendored"
]
]

View File

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

View File

@@ -523,9 +523,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* e.g. `ui.desktop.foo`, `ui.desktop.linux.bar`, `ui.android.foo`, `ui.dc40.bar`, `ui.bot.simplebot.baz`.
* These keys go to backups and allow easy per-account settings when using @ref dc_accounts_t,
* however, are not handled by the core otherwise.
* - `webxdc_realtime_enabled` = Whether the realtime APIs should be enabled.
* 0 = WebXDC realtime API is disabled and behaves as noop (default).
* 1 = WebXDC realtime API is enabled.
*
* If you want to retrieve a value, use dc_get_config().
*

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "1.140.0"
version = "1.139.5"
description = "DeltaChat JSON-RPC API"
edition = "2021"
default-run = "deltachat-jsonrpc-server"

View File

@@ -58,5 +58,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "1.140.0"
"version": "1.139.5"
}

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "1.140.0"
version = "1.139.5"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,8 +17,6 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Chat",
"Topic :: Communications :: Email"
]

View File

@@ -177,7 +177,8 @@ class Rpc:
account_id = event["contextId"]
queue = self.get_queue(account_id)
event = event["event"]
logging.debug("account_id=%d got an event %s", account_id, event)
# logging.debug("account_id=%d got an event %s", account_id, event)
print("account_id=%d got an event %s" % (account_id, event))
queue.put(event)
except Exception:
# Log an exception if the event loop dies.

View File

@@ -29,8 +29,6 @@ def log(msg):
def setup_realtime_webxdc(ac1, ac2, path_to_webxdc):
assert ac1.get_config("webxdc_realtime_enabled") == "1"
assert ac2.get_config("webxdc_realtime_enabled") == "1"
ac1_ac2_chat = ac1.create_chat(ac2)
ac2.create_chat(ac1)
@@ -77,8 +75,6 @@ def wait_receive_realtime_data(msg_data_list):
def test_realtime_sequentially(acfactory, path_to_webxdc):
"""Test two peers trying to establish connection sequentially."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1.create_chat(ac2)
ac2.create_chat(ac1)
@@ -119,8 +115,6 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
def test_realtime_simultaneously(acfactory, path_to_webxdc):
"""Test two peers trying to establish connection simultaneously."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
@@ -131,8 +125,6 @@ def test_realtime_simultaneously(acfactory, path_to_webxdc):
def test_two_parallel_realtime_simultaneously(acfactory, path_to_webxdc):
"""Test two peers trying to establish connection simultaneously."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
ac1_webxdc_msg2, ac2_webxdc_msg2 = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
@@ -149,8 +141,6 @@ def test_two_parallel_realtime_simultaneously(acfactory, path_to_webxdc):
def test_no_duplicate_messages(acfactory, path_to_webxdc):
"""Test that messages are received only once."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_ac2_chat = ac1.create_chat(ac2)

View File

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

View File

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

View File

@@ -139,4 +139,5 @@ license-files = [
github = [
"async-email",
"deltachat",
"n0-computer",
]

View File

@@ -55,5 +55,5 @@
"test:mocha": "mocha node/test/test.mjs --growl --reporter=spec --bail --exit"
},
"types": "node/dist/index.d.ts",
"version": "1.140.0"
"version": "1.139.5"
}

View File

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

View File

@@ -1 +1 @@
2024-06-04
2024-05-23

View File

@@ -31,6 +31,6 @@ unset CHATMAIL_DOMAIN
# Try to build wheels for a range of interpreters, but don't fail if they are not available.
# E.g. musllinux_1_1 does not have PyPy interpreters as of 2022-07-10
tox --workdir "$TOXWORKDIR" -e py37,py38,py39,py310,py311,py312,py313,pypy37,pypy38,pypy39,pypy310 --skip-missing-interpreters true
tox --workdir "$TOXWORKDIR" -e py37,py38,py39,py310,py311,py312,pypy37,pypy38,pypy39,pypy310 --skip-missing-interpreters true
auditwheel repair "$TOXWORKDIR"/wheelhouse/deltachat* -w "$TOXWORKDIR/wheelhouse"

View File

@@ -3,7 +3,7 @@
use core::cmp::max;
use std::ffi::OsStr;
use std::fmt;
use std::io::{Cursor, Seek};
use std::io::Cursor;
use std::iter::FusedIterator;
use std::mem;
use std::path::{Path, PathBuf};
@@ -12,7 +12,6 @@ use anyhow::{format_err, Context as _, Result};
use base64::Engine as _;
use futures::StreamExt;
use image::codecs::jpeg::JpegEncoder;
use image::io::Reader as ImageReader;
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
use num_traits::FromPrimitive;
use tokio::io::AsyncWriteExt;
@@ -427,25 +426,9 @@ impl<'a> BlobObject<'a> {
let mut no_exif = false;
let no_exif_ref = &mut no_exif;
let res = tokio::task::block_in_place(move || {
let mut file = std::fs::File::open(self.to_abs_path())?;
let (nr_bytes, exif) = image_metadata(&file)?;
let (nr_bytes, exif) = self.metadata()?;
*no_exif_ref = exif.is_none();
// It's strange that BufReader modifies a file position while it takes a non-mut
// reference. Ok, just rewind it.
file.rewind()?;
let imgreader = ImageReader::new(std::io::BufReader::new(&file)).with_guessed_format();
let imgreader = match imgreader {
Ok(ir) => ir,
_ => {
file.rewind()?;
ImageReader::with_format(
std::io::BufReader::new(&file),
ImageFormat::from_path(&blob_abs)?,
)
}
};
let fmt = imgreader.format().context("No format??")?;
let mut img = imgreader.decode().context("image decode failure")?;
let mut img = image::open(&blob_abs).context("image decode failure")?;
let orientation = exif.as_ref().map(|exif| exif_orientation(exif, context));
let mut encoded = Vec::new();
let mut changed_name = None;
@@ -474,9 +457,10 @@ impl<'a> BlobObject<'a> {
let exceeds_max_bytes = nr_bytes > max_bytes as u64;
let jpeg_quality = 75;
let fmt = ImageFormat::from_path(&blob_abs);
let ofmt = match fmt {
ImageFormat::Png if !exceeds_max_bytes => ImageOutputFormat::Png,
ImageFormat::Jpeg => {
Ok(ImageFormat::Png) if !exceeds_max_bytes => ImageOutputFormat::Png,
Ok(ImageFormat::Jpeg) => {
add_white_bg = false;
ImageOutputFormat::Jpeg {
quality: jpeg_quality,
@@ -513,7 +497,7 @@ impl<'a> BlobObject<'a> {
img_wh = max(img.width(), img.height());
// PNGs and WebPs may be huge because of animation, which is lost by the `image`
// crate when recoding, so don't scale them down.
if matches!(fmt, ImageFormat::Jpeg) || !encoded.is_empty() {
if matches!(fmt, Ok(ImageFormat::Jpeg)) || !encoded.is_empty() {
img_wh = img_wh * 2 / 3;
}
}
@@ -554,7 +538,7 @@ impl<'a> BlobObject<'a> {
if do_scale || exif.is_some() {
// The file format is JPEG/PNG now, we may have to change the file extension
if !matches!(fmt, ImageFormat::Jpeg)
if !matches!(fmt, Ok(ImageFormat::Jpeg))
&& matches!(ofmt, ImageOutputFormat::Jpeg { .. })
{
blob_abs = blob_abs.with_extension("jpg");
@@ -591,14 +575,15 @@ impl<'a> BlobObject<'a> {
}
}
}
}
/// Returns image file size and Exif.
pub fn image_metadata(file: &std::fs::File) -> Result<(u64, Option<exif::Exif>)> {
let len = file.metadata()?.len();
let mut bufreader = std::io::BufReader::new(file);
let exif = exif::Reader::new().read_from_container(&mut bufreader).ok();
Ok((len, exif))
/// Returns image file size and Exif.
pub fn metadata(&self) -> Result<(u64, Option<exif::Exif>)> {
let file = std::fs::File::open(self.to_abs_path())?;
let len = file.metadata()?.len();
let mut bufreader = std::io::BufReader::new(&file);
let exif = exif::Reader::new().read_from_container(&mut bufreader).ok();
Ok((len, exif))
}
}
fn exif_orientation(exif: &exif::Exif, context: &Context) -> i32 {
@@ -1321,7 +1306,8 @@ mod tests {
.context("failed to write file")?;
check_image_size(&file, original_width, original_height);
let (_, exif) = image_metadata(&std::fs::File::open(&file)?)?;
let blob = BlobObject::new_from_path(&alice, &file).await?;
let (_, exif) = blob.metadata()?;
if has_exif {
let exif = exif.unwrap();
assert_eq!(exif_orientation(&exif, &alice), orientation);
@@ -1356,7 +1342,8 @@ mod tests {
assert_eq!(&bytes1, bytes);
}
let (_, exif) = image_metadata(&std::fs::File::open(&file_saved)?)?;
let blob = BlobObject::new_from_path(&bob, &file_saved).await?;
let (_, exif) = blob.metadata()?;
assert!(exif.is_none());
let img = check_image_size(file_saved, compressed_width, compressed_height);
@@ -1392,7 +1379,8 @@ mod tests {
.get_blobdir()
.join("saved-".to_string() + &bob_msg.get_filename().unwrap());
bob_msg.save_file(&bob, &file_saved).await?;
let (file_size, _) = image_metadata(&std::fs::File::open(&file_saved)?)?;
let blob = BlobObject::new_from_path(&bob, &file_saved).await?;
let (file_size, _) = blob.metadata()?;
assert_eq!(file_size, bytes.len() as u64);
check_image_size(file_saved, width, height);
Ok(())

View File

@@ -1932,10 +1932,6 @@ impl Chat {
// reset encrypt error state eg. for forwarding
msg.param.remove(Param::ErroneousE2ee);
let is_bot = context.get_config_bool(Config::Bot).await?;
msg.param
.set_optional(Param::Bot, Some("1").filter(|_| is_bot));
// Set "In-Reply-To:" to identify the message to which the composed message is a reply.
// Set "References:" to identify the "thread" of the conversation.
// Both according to [RFC 5322 3.6.4, page 25](https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4).
@@ -2995,7 +2991,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
msg.subject.clone_from(&rendered_msg.subject);
msg.update_subject(context).await?;
let chunk_size = context.get_max_smtp_rcpt_to().await?;
let chunk_size = context
.get_configured_provider()
.await?
.and_then(|provider| provider.opt.max_smtp_rcpt_to)
.map_or(constants::DEFAULT_MAX_SMTP_RCPT_TO, usize::from);
let trans_fn = |t: &mut rusqlite::Transaction| {
let mut row_ids = Vec::<i64>::new();
for recipients_chunk in recipients.chunks(chunk_size) {
@@ -7528,27 +7528,4 @@ mod tests {
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast list 42");
Ok(())
}
/// Tests sending JPEG image with .png extension.
///
/// This is a regression test, previously sending failed
/// because image was passed to PNG decoder
/// and it failed to decode image.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_jpeg_with_png_ext() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let bytes = include_bytes!("../test-data/image/screenshot.jpg");
let file = alice.get_blobdir().join("screenshot.png");
tokio::fs::write(&file, bytes).await?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file(file.to_str().unwrap(), None);
let alice_chat = alice.create_chat(&bob).await;
let sent_msg = alice.send_msg(alice_chat.get_id(), &mut msg).await;
let _msg = bob.recv_msg(&sent_msg).await;
Ok(())
}
}

View File

@@ -363,8 +363,8 @@ pub enum Config {
/// MsgId of webxdc map integration.
WebxdcIntegration,
/// Enable webxdc realtime features.
WebxdcRealtimeEnabled,
/// Iroh secret key.
IrohSecretKey,
}
impl Config {

View File

@@ -498,23 +498,6 @@ impl Context {
self.get_config_bool(Config::IsChatmail).await
}
/// Returns maximum number of recipients the provider allows to send a single email to.
pub(crate) async fn get_max_smtp_rcpt_to(&self) -> Result<usize> {
let is_chatmail = self.is_chatmail().await?;
let val = self
.get_configured_provider()
.await?
.and_then(|provider| provider.opt.max_smtp_rcpt_to)
.map_or_else(
|| match is_chatmail {
true => usize::MAX,
false => constants::DEFAULT_MAX_SMTP_RCPT_TO,
},
usize::from,
);
Ok(val)
}
/// Does a background fetch
/// pauses the scheduler and does one imap fetch, then unpauses and returns
pub async fn background_fetch(&self) -> Result<()> {
@@ -967,12 +950,6 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"webxdc_realtime_enabled",
self.get_config_bool(Config::WebxdcRealtimeEnabled)
.await?
.to_string(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
@@ -1679,6 +1656,7 @@ mod tests {
"socks5_password",
"key_id",
"webxdc_integration",
"iroh_secret_key",
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();

View File

@@ -184,7 +184,7 @@ impl Session {
bail!("Attempt to fetch UID 0");
}
self.select_folder(context, folder).await?;
self.select_folder(context, Some(folder)).await?;
// we are connected, and the folder is selected
info!(context, "Downloading message {}/{} fully...", folder, uid);

View File

@@ -838,7 +838,7 @@ impl Session {
// Collect pairs of UID and Message-ID.
let mut msgs = BTreeMap::new();
self.select_folder(context, folder).await?;
self.select_folder(context, Some(folder)).await?;
let mut list = self
.uid_fetch("1:*", RFC724MID_UID)
@@ -1039,7 +1039,7 @@ impl Session {
// MOVE/DELETE operations. This does not result in multiple SELECT commands
// being sent because `select_folder()` does nothing if the folder is already
// selected.
self.select_folder(context, folder).await?;
self.select_folder(context, Some(folder)).await?;
// Empty target folder name means messages should be deleted.
if target.is_empty() {
@@ -1087,7 +1087,7 @@ impl Session {
.await?;
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
self.select_folder(context, &folder)
self.select_folder(context, Some(&folder))
.await
.context("failed to select folder")?;
@@ -1131,7 +1131,7 @@ impl Session {
return Ok(());
}
self.select_folder(context, folder)
self.select_folder(context, Some(folder))
.await
.context("failed to select folder")?;
@@ -1563,7 +1563,7 @@ impl Session {
) -> Result<Option<&'a str>> {
// Close currently selected folder if needed.
// We are going to select folders using low-level EXAMINE operations below.
self.maybe_close_folder(context).await?;
self.select_folder(context, None).await?;
for folder in folders {
info!(context, "Looking for MVBOX-folder \"{}\"...", &folder);

View File

@@ -29,7 +29,7 @@ impl Session {
) -> Result<Self> {
use futures::future::FutureExt;
self.select_folder(context, folder).await?;
self.select_folder(context, Some(folder)).await?;
if self.server_sent_unsolicited_exists(context)? {
return Ok(self);

View File

@@ -55,13 +55,15 @@ impl ImapSession {
pub(crate) async fn select_folder(
&mut self,
context: &Context,
folder: &str,
folder: Option<&str>,
) -> Result<NewlySelected> {
// if there is a new folder and the new folder is equal to the selected one, there's nothing to do.
// if there is _no_ new folder, we continue as we might want to expunge below.
if let Some(selected_folder) = &self.selected_folder {
if folder == selected_folder {
return Ok(NewlySelected::No);
if let Some(folder) = folder {
if let Some(selected_folder) = &self.selected_folder {
if folder == selected_folder {
return Ok(NewlySelected::No);
}
}
}
@@ -69,30 +71,34 @@ impl ImapSession {
self.maybe_close_folder(context).await?;
// select new folder
let res = if self.can_condstore() {
self.select_condstore(folder).await
if let Some(folder) = folder {
let res = if self.can_condstore() {
self.select_condstore(folder).await
} else {
self.select(folder).await
};
// <https://tools.ietf.org/html/rfc3501#section-6.3.1>
// says that if the server reports select failure we are in
// authenticated (not-select) state.
match res {
Ok(mailbox) => {
self.selected_folder = Some(folder.to_string());
self.selected_mailbox = Some(mailbox);
Ok(NewlySelected::Yes)
}
Err(async_imap::error::Error::ConnectionLost) => Err(Error::ConnectionLost),
Err(async_imap::error::Error::Validate(_)) => {
Err(Error::BadFolderName(folder.to_string()))
}
Err(async_imap::error::Error::No(response)) => {
Err(Error::NoFolder(folder.to_string(), response))
}
Err(err) => Err(Error::Other(err.to_string())),
}
} else {
self.select(folder).await
};
// <https://tools.ietf.org/html/rfc3501#section-6.3.1>
// says that if the server reports select failure we are in
// authenticated (not-select) state.
match res {
Ok(mailbox) => {
self.selected_folder = Some(folder.to_string());
self.selected_mailbox = Some(mailbox);
Ok(NewlySelected::Yes)
}
Err(async_imap::error::Error::ConnectionLost) => Err(Error::ConnectionLost),
Err(async_imap::error::Error::Validate(_)) => {
Err(Error::BadFolderName(folder.to_string()))
}
Err(async_imap::error::Error::No(response)) => {
Err(Error::NoFolder(folder.to_string(), response))
}
Err(err) => Err(Error::Other(err.to_string())),
Ok(NewlySelected::No)
}
}
@@ -102,7 +108,7 @@ impl ImapSession {
context: &Context,
folder: &str,
) -> anyhow::Result<NewlySelected> {
match self.select_folder(context, folder).await {
match self.select_folder(context, Some(folder)).await {
Ok(newly_selected) => Ok(newly_selected),
Err(err) => match err {
Error::NoFolder(..) => {
@@ -112,7 +118,7 @@ impl ImapSession {
info!(context, "Couldn't select folder, then create() failed: {err:#}.");
// Need to recheck, could have been created in parallel.
}
let select_res = self.select_folder(context, folder).await.with_context(|| format!("failed to select newely created folder {folder}"));
let select_res = self.select_folder(context, Some(folder)).await.with_context(|| format!("failed to select newely created folder {folder}"));
if select_res.is_err() {
create_res?;
}

View File

@@ -1433,7 +1433,7 @@ pub(crate) fn guess_msgtype_from_suffix(path: &Path) -> Option<(Viewtype, &str)>
"txt" => (Viewtype::File, "text/plain"),
"vcard" => (Viewtype::Vcard, "text/vcard"),
"vcf" => (Viewtype::Vcard, "text/vcard"),
"wav" => (Viewtype::Audio, "audio/wav"),
"wav" => (Viewtype::File, "audio/wav"),
"weba" => (Viewtype::File, "audio/webm"),
"webm" => (Viewtype::Video, "video/webm"),
"webp" => (Viewtype::Image, "image/webp"), // iOS via SDWebImage, Android since 4.0

View File

@@ -25,6 +25,7 @@
use anyhow::{anyhow, Context as _, Result};
use email::Header;
use futures_lite::StreamExt;
use iroh_gossip::net::{Gossip, JoinTopicFut, GOSSIP_ALPN};
use iroh_gossip::proto::{Event as IrohEvent, TopicId};
use iroh_net::relay::{RelayMap, RelayUrl};
@@ -37,7 +38,6 @@ use tokio::task::JoinHandle;
use url::Url;
use crate::chat::send_msg;
use crate::config::Config;
use crate::context::Context;
use crate::headerdef::HeaderDef;
use crate::message::{Message, MsgId, Viewtype};
@@ -158,10 +158,6 @@ impl Iroh {
self.gossip.broadcast(topic, data.into()).await?;
if env::var("REALTIME_DEBUG").is_ok() {
info!(ctx, "Sent realtime data");
}
Ok(())
}
@@ -247,6 +243,15 @@ impl Context {
// Shuts down on deltachat shutdown
tokio::spawn(endpoint_loop(context, endpoint.clone(), gossip.clone()));
let endp = endpoint.clone();
let gsp = gossip.clone();
tokio::spawn(async move {
let mut stream = endp.local_endpoints();
while let Some(endpoints) = stream.next().await {
gsp.update_endpoints(&endpoints)?;
}
anyhow::Ok(())
});
Ok(Iroh {
endpoint,
gossip,
@@ -339,10 +344,6 @@ pub async fn send_webxdc_realtime_advertisement(
ctx: &Context,
msg_id: MsgId,
) -> Result<Option<JoinTopicFut>> {
if !ctx.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
return Ok(None);
}
let iroh = ctx.get_or_try_init_peer_channel().await?;
let conn = iroh.join_and_subscribe_gossip(ctx, msg_id).await?;
@@ -352,16 +353,14 @@ pub async fn send_webxdc_realtime_advertisement(
msg.param.set_cmd(SystemMessage::IrohNodeAddr);
msg.in_reply_to = Some(webxdc.rfc724_mid.clone());
send_msg(ctx, webxdc.chat_id, &mut msg).await?;
info!(ctx, "IROH_REALTIME: Sent realtime advertisement");
if env::var("REALTIME_DEBUG").is_ok() {
info!(ctx, "IROH_REALTIME: Sent realtime advertisement");
}
Ok(conn)
}
/// Send realtime data to other peers using iroh.
/// Send realtime data to the gossip swarm.
pub async fn send_webxdc_realtime_data(ctx: &Context, msg_id: MsgId, data: Vec<u8>) -> Result<()> {
if !ctx.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
return Ok(());
}
let iroh = ctx.get_or_try_init_peer_channel().await?;
iroh.send_webxdc_realtime_data(ctx, msg_id, data).await?;
Ok(())
@@ -369,13 +368,10 @@ pub async fn send_webxdc_realtime_data(ctx: &Context, msg_id: MsgId, data: Vec<u
/// Leave the gossip of the webxdc with given [MsgId].
pub async fn leave_webxdc_realtime(ctx: &Context, msg_id: MsgId) -> Result<()> {
if !ctx.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
return Ok(());
}
let iroh = ctx.get_or_try_init_peer_channel().await?;
iroh.leave_realtime(get_iroh_topic_for_msg(ctx, msg_id).await?)
.await?;
info!(ctx, "IROH_REALTIME: Left gossip for message {msg_id}");
Ok(())
@@ -443,11 +439,12 @@ async fn subscribe_loop(
let event = stream.recv().await?;
match event {
IrohEvent::NeighborUp(node) => {
info!(context, "IROH_REALTIME: NeighborUp: {}", node.to_string());
iroh_add_peer_for_topic(context, msg_id, topic, node, None).await?;
}
IrohEvent::Received(event) => {
info!(context, "IROH_REALTIME: Received realtime data");
if env::var("REALTIME_DEBUG").is_ok() {
info!(context, "IROH_REALTIME: Received realtime data");
}
context.emit_event(EventType::WebxdcRealtimeData {
msg_id,
data: event
@@ -478,17 +475,6 @@ mod tests {
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
bob.ctx
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
alice
.ctx
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
@@ -620,21 +606,6 @@ mod tests {
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
bob.ctx
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
alice
.ctx
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
assert!(alice
.get_config_bool(Config::WebxdcRealtimeEnabled)
.await
.unwrap());
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
@@ -770,17 +741,6 @@ mod tests {
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
bob.ctx
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
alice
.ctx
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
@@ -843,29 +803,4 @@ mod tests {
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_peer_channels_disabled() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
// creates iroh endpoint as side effect
send_webxdc_realtime_advertisement(alice, MsgId::new(1))
.await
.unwrap();
assert!(alice.ctx.iroh.get().is_none());
// creates iroh endpoint as side effect
send_webxdc_realtime_data(alice, MsgId::new(1), vec![])
.await
.unwrap();
assert!(alice.ctx.iroh.get().is_none());
// creates iroh endpoint as side effect
leave_webxdc_realtime(alice, MsgId::new(1)).await.unwrap();
assert!(alice.ctx.iroh.get().is_none())
}
}

View File

@@ -538,18 +538,10 @@ impl Peerstate {
if let Some(old_addr) = old_addr {
// We are doing an AEAP transition to the new address and the SQL INSERT below will
// save the existing peerstate as belonging to this new address. We now need to
// "unverify" the peerstate that belongs to the current address in case if the
// contact later wants to move back to the current address. Otherwise the old entry
// will be just found and updated instead of doing AEAP. We can't just delete the
// existing peerstate as this would break encryption to it. This is critical for
// non-verified groups -- if we can't encrypt to the old address, we can't securely
// remove it from the group (to add the new one instead).
t.execute(
"UPDATE acpeerstates \
SET verified_key=NULL, verified_key_fingerprint='', verifier='' \
WHERE addr=?",
(old_addr,),
)?;
// delete the peerstate that belongs to the current address in case if the contact
// later wants to move back to the current address. Otherwise the old entry will be
// just found and updated instead of doing AEAP.
t.execute("DELETE FROM acpeerstates WHERE addr=?", (old_addr,))?;
}
t.execute(
"INSERT INTO acpeerstates (

View File

@@ -782,6 +782,29 @@ async fn add_parts(
info!(context, "Message is an MDN (TRASH).",);
}
// Try to assign to a chat based on Chat-Group-ID.
if chat_id.is_none() {
if let Some(grpid) = mime_parser.get_chat_group_id() {
if let Some((id, _protected, blocked)) =
chat::get_chat_id_by_grpid(context, grpid).await?
{
chat_id = Some(id);
chat_id_blocked = blocked;
}
}
}
if chat_id.is_none() {
// try to assign to a chat based on In-Reply-To/References:
if let Some((new_chat_id, new_chat_id_blocked)) =
lookup_chat_by_reply(context, mime_parser, &parent, to_ids, from_id).await?
{
chat_id = Some(new_chat_id);
chat_id_blocked = new_chat_id_blocked;
}
}
// signals whether the current user is a bot
let is_bot = context.get_config_bool(Config::Bot).await?;
@@ -811,44 +834,17 @@ async fn add_parts(
create_blocked_default
};
// Try to assign to a chat based on Chat-Group-ID.
if chat_id.is_none() {
if let Some(grpid) = mime_parser.get_chat_group_id().map(|s| s.to_string()) {
if let Some((id, _protected, blocked)) =
chat::get_chat_id_by_grpid(context, &grpid).await?
{
chat_id = Some(id);
chat_id_blocked = blocked;
} else if allow_creation || test_normal_chat.is_some() {
if let Some((new_chat_id, new_chat_id_blocked)) = create_group(
context,
mime_parser,
is_partial_download.is_some(),
create_blocked,
from_id,
to_ids,
&verified_encryption,
&grpid,
)
.await?
{
chat_id = Some(new_chat_id);
chat_id_blocked = new_chat_id_blocked;
}
}
}
}
if chat_id.is_none() && (allow_creation || test_normal_chat.is_some()) {
// try to create a group
if chat_id.is_none() {
if let Some((new_chat_id, new_chat_id_blocked)) = lookup_chat_or_create_adhoc_group(
if let Some((new_chat_id, new_chat_id_blocked)) = create_group(
context,
mime_parser,
&parent,
to_ids,
from_id,
allow_creation || test_normal_chat.is_some(),
create_blocked,
is_partial_download.is_some(),
create_blocked,
from_id,
to_ids,
&verified_encryption,
)
.await?
{
@@ -1050,32 +1046,27 @@ async fn add_parts(
// Try to assign to a chat based on Chat-Group-ID.
if chat_id.is_none() {
if let Some(grpid) = mime_parser.get_chat_group_id().map(|s| s.to_string()) {
if let Some(grpid) = mime_parser.get_chat_group_id() {
if let Some((id, _protected, blocked)) =
chat::get_chat_id_by_grpid(context, &grpid).await?
chat::get_chat_id_by_grpid(context, grpid).await?
{
chat_id = Some(id);
chat_id_blocked = blocked;
} else if allow_creation {
if let Some((new_chat_id, new_chat_id_blocked)) = create_group(
context,
mime_parser,
is_partial_download.is_some(),
Blocked::Not,
from_id,
to_ids,
&verified_encryption,
&grpid,
)
.await?
{
chat_id = Some(new_chat_id);
chat_id_blocked = new_chat_id_blocked;
}
}
}
}
if chat_id.is_none() {
// try to assign to a chat based on In-Reply-To/References:
if let Some((new_chat_id, new_chat_id_blocked)) =
lookup_chat_by_reply(context, mime_parser, &parent, to_ids, from_id).await?
{
chat_id = Some(new_chat_id);
chat_id_blocked = new_chat_id_blocked;
}
}
if mime_parser.decrypting_failed && !fetching_existing_messages {
if chat_id.is_none() {
chat_id = Some(DC_CHAT_ID_TRASH);
@@ -1107,25 +1098,23 @@ async fn add_parts(
}
}
if chat_id.is_none() {
if let Some((new_chat_id, new_chat_id_blocked)) = lookup_chat_or_create_adhoc_group(
context,
mime_parser,
&parent,
to_ids,
from_id,
allow_creation,
Blocked::Not,
is_partial_download.is_some(),
)
.await?
{
chat_id = Some(new_chat_id);
chat_id_blocked = new_chat_id_blocked;
}
}
if !to_ids.is_empty() {
if chat_id.is_none() && allow_creation {
if let Some((new_chat_id, new_chat_id_blocked)) = create_group(
context,
mime_parser,
is_partial_download.is_some(),
Blocked::Not,
from_id,
to_ids,
&verified_encryption,
)
.await?
{
chat_id = Some(new_chat_id);
chat_id_blocked = new_chat_id_blocked;
}
}
if chat_id.is_none() && allow_creation {
let to_contact = Contact::get_by_id(context, to_id).await?;
if let Some(list_id) = to_contact.param.get(Param::ListId) {
@@ -1790,44 +1779,6 @@ async fn lookup_chat_by_reply(
Ok(Some((parent_chat.id, parent_chat.blocked)))
}
#[allow(clippy::too_many_arguments)]
async fn lookup_chat_or_create_adhoc_group(
context: &Context,
mime_parser: &MimeMessage,
parent: &Option<Message>,
to_ids: &[ContactId],
from_id: ContactId,
allow_creation: bool,
create_blocked: Blocked,
is_partial_download: bool,
) -> Result<Option<(ChatId, Blocked)>> {
if let Some((new_chat_id, new_chat_id_blocked)) =
// Try to assign to a chat based on In-Reply-To/References.
lookup_chat_by_reply(context, mime_parser, parent, to_ids, from_id).await?
{
Ok(Some((new_chat_id, new_chat_id_blocked)))
} else if allow_creation {
// Try to create an ad hoc group.
if let Some(new_chat_id) = create_adhoc_group(
context,
mime_parser,
create_blocked,
from_id,
to_ids,
is_partial_download,
)
.await
.context("Could not create ad hoc group")?
{
Ok(Some((new_chat_id, create_blocked)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
/// If this method returns true, the message shall be assigned to the 1:1 chat with the sender.
/// If it returns false, it shall be assigned to the parent chat.
async fn is_probably_private_reply(
@@ -1869,8 +1820,7 @@ async fn is_probably_private_reply(
/// chat with this ID. If there is no group-id and there are more
/// than two members, a new ad hoc group is created.
///
/// On success the function returns the created (chat_id, chat_blocked) tuple.
#[allow(clippy::too_many_arguments)]
/// On success the function returns the found/created (chat_id, chat_blocked) tuple.
async fn create_group(
context: &Context,
mime_parser: &mut MimeMessage,
@@ -1879,8 +1829,34 @@ async fn create_group(
from_id: ContactId,
to_ids: &[ContactId],
verified_encryption: &VerifiedEncryption,
grpid: &str,
) -> Result<Option<(ChatId, Blocked)>> {
let Some(grpid) = mime_parser.get_chat_group_id().map(|s| s.to_string()) else {
if is_partial_download {
// Partial download may be an encrypted message with protected Subject header.
//
// We do not want to create a group with "..." or "Encrypted message" as a subject.
info!(
context,
"Ad-hoc group cannot be created from partial download."
);
return Ok(None);
}
let mut member_ids: Vec<ContactId> = to_ids.to_vec();
if !member_ids.contains(&(from_id)) {
member_ids.push(from_id);
}
if !member_ids.contains(&(ContactId::SELF)) {
member_ids.push(ContactId::SELF);
}
let res = create_adhoc_group(context, mime_parser, create_blocked, &member_ids)
.await
.context("could not create ad hoc group")?
.map(|chat_id| (chat_id, create_blocked));
return Ok(res);
};
let mut chat_id = None;
let mut chat_id_blocked = Default::default();
@@ -1923,7 +1899,7 @@ async fn create_group(
// otherwise, a pending "quit" message may pop up
&& mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved).is_none()
// re-create explicitly left groups only if ourself is re-added
&& (!chat::is_group_explicitly_left(context, grpid).await?
&& (!chat::is_group_explicitly_left(context, &grpid).await?
|| self_explicitly_added(context, &mime_parser).await?)
{
// Group does not exist but should be created.
@@ -1936,7 +1912,7 @@ async fn create_group(
let new_chat_id = ChatId::create_multiuser_record(
context,
Chattype::Group,
grpid,
&grpid,
grpname,
create_blocked,
create_protected,
@@ -2494,29 +2470,8 @@ async fn create_adhoc_group(
context: &Context,
mime_parser: &MimeMessage,
create_blocked: Blocked,
from_id: ContactId,
to_ids: &[ContactId],
is_partial_download: bool,
member_ids: &[ContactId],
) -> Result<Option<ChatId>> {
if is_partial_download {
// Partial download may be an encrypted message with protected Subject header.
//
// We do not want to create a group with "..." or "Encrypted message" as a subject.
info!(
context,
"Ad-hoc group cannot be created from partial download."
);
return Ok(None);
}
let mut member_ids: Vec<ContactId> = to_ids.to_vec();
if !member_ids.contains(&(from_id)) {
member_ids.push(from_id);
}
if !member_ids.contains(&(ContactId::SELF)) {
member_ids.push(ContactId::SELF);
}
if mime_parser.is_mailinglist_message() {
return Ok(None);
}
@@ -2562,7 +2517,7 @@ async fn create_adhoc_group(
context,
"Created ad-hoc group id={new_chat_id}, name={grpname:?}."
);
chat::add_to_chat_contacts_table(context, new_chat_id, &member_ids).await?;
chat::add_to_chat_contacts_table(context, new_chat_id, member_ids).await?;
context.emit_event(EventType::ChatModified(new_chat_id));
chatlist_events::emit_chatlist_changed(context);

View File

@@ -1820,31 +1820,26 @@ async fn test_save_mime_headers_on() -> anyhow::Result<()> {
Ok(())
}
async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: bool) {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
async fn create_test_alias(chat_request: bool, group_request: bool) -> (TestContext, TestContext) {
// Claire, a customer, sends a support request
// to the alias address <support@example.org>.
// If `chat_request` is true, Claire is using Delta Chat,
// otherwise Claire sends the request from a classic MUA.
// to the alias address <support@example.org> from a classic MUA.
// The alias expands to the supporters Alice and Bob.
// Check that Alice receives the message in a group chat.
let claire_request = if group_request {
format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
To: support@example.org, ceo@example.org\n\
From: claire@example.org\n\
Subject: i have a question\n\
Message-ID: <non-dc-1@example.org>\n\
{}\
Date: Sun, 14 Mar 2021 17:04:36 +0100\n\
Content-Type: text/plain\n\
\n\
hi support! what is the current version?",
To: support@example.org, ceo@example.org\n\
From: claire@example.org\n\
Subject: i have a question\n\
Message-ID: <non-dc-1@example.org>\n\
{}\
Date: Sun, 14 Mar 2021 17:04:36 +0100\n\
Content-Type: text/plain\n\
\n\
hi support! what is the current version?",
if chat_request {
"Chat-Version: 1.0\n\
Chat-Group-ID: 8ud29aridt29arid\n\
Chat-Group-Name: =?utf-8?q?i_have_a_question?=\n"
"Chat-Group-ID: 8ud29aridt29arid\n\
Chat-Group-Name: =?utf-8?q?i_have_a_question?=\n"
} else {
""
}
@@ -1852,15 +1847,15 @@ async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: boo
} else {
format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
To: support@example.org\n\
From: claire@example.org\n\
Subject: i have a question\n\
Message-ID: <non-dc-1@example.org>\n\
{}\
Date: Sun, 14 Mar 2021 17:04:36 +0100\n\
Content-Type: text/plain\n\
\n\
hi support! what is the current version?",
To: support@example.org\n\
From: claire@example.org\n\
Subject: i have a question\n\
Message-ID: <non-dc-1@example.org>\n\
{}\
Date: Sun, 14 Mar 2021 17:04:36 +0100\n\
Content-Type: text/plain\n\
\n\
hi support! what is the current version?",
if chat_request {
"Chat-Version: 1.0\n"
} else {
@@ -1869,11 +1864,11 @@ async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: boo
)
};
let alice = TestContext::new_alice().await;
receive_imf(&alice, claire_request.as_bytes(), false)
.await
.unwrap();
// Check that Alice receives the message in a group chat.
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_subject(), "i have a question");
assert!(msg.get_text().contains("hi support!"));
@@ -1887,7 +1882,7 @@ async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: boo
}
assert_eq!(msg.get_override_sender_name(), None);
let claire = tcm.unconfigured().await;
let claire = TestContext::new().await;
claire.configure_addr("claire@example.org").await;
receive_imf(&claire, claire_request.as_bytes(), false)
.await
@@ -1911,48 +1906,15 @@ async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: boo
assert_eq!(get_chat_msgs(&claire, chat.id).await.unwrap().len(), 1);
assert_eq!(msg.get_override_sender_name(), None);
let reply = if from_dc {
// Bob, the other supporter, answers with Delta Chat.
format!(
"To: support@example.org, claire@example.org\n\
From: bob@example.net\n\
Subject: =?utf-8?q?Re=3A_i_have_a_question?=\n\
References: <bobreply@localhost>\n\
In-Reply-To: <non-dc-1@example.org>\n\
Message-ID: <bobreply@localhost>\n\
Date: Sun, 14 Mar 2021 16:04:57 +0000\n\
Chat-Version: 1.0\n\
{}\
Chat-Group-Name: =?utf-8?q?i_have_a_question?=\n\
Chat-Disposition-Notification-To: bob@example.net\n\
Content-Type: text/plain\n\
\n\
hi claire, the version is 1.0, cheers bob",
if group_request && chat_request {
"Chat-Group-ID: 8ud29aridt29arid\n"
} else {
// Ad-hoc group has no ID.
""
}
)
} else {
// Bob, the other supporter, answers with a classic MUA.
"To: support@example.org, claire@example.org\n\
From: bob@example.net\n\
Subject: =?utf-8?q?Re=3A_i_have_a_question?=\n\
References: <non-dc-1@example.org>\n\
In-Reply-To: <non-dc-1@example.org>\n\
Message-ID: <non-dc-2@example.net>\n\
Date: Sun, 14 Mar 2021 16:04:57 +0000\n\
Content-Type: text/plain\n\
\n\
hi claire, the version is 1.0, cheers bob"
.to_string()
};
(claire, alice)
}
async fn check_alias_reply(reply: &[u8], chat_request: bool, group_request: bool) {
let (claire, alice) = create_test_alias(chat_request, group_request).await;
// Check that Alice gets the message in the same chat.
let request = alice.get_last_msg().await;
receive_imf(&alice, reply.as_bytes(), false).await.unwrap();
receive_imf(&alice, reply, false).await.unwrap();
let answer = alice.get_last_msg().await;
assert_eq!(answer.get_subject(), "Re: i have a question");
assert!(answer.get_text().contains("the version is 1.0"));
@@ -1975,7 +1937,7 @@ async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: boo
// Check that Claire also gets the message in the same chat.
let request = claire.get_last_msg().await;
receive_imf(&claire, reply.as_bytes(), false).await.unwrap();
receive_imf(&claire, reply, false).await.unwrap();
let answer = claire.get_last_msg().await;
assert_eq!(answer.get_subject(), "Re: i have a question");
assert!(answer.get_text().contains("the version is 1.0"));
@@ -1987,36 +1949,47 @@ async fn check_alias_reply(from_dc: bool, chat_request: bool, group_request: boo
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_nondc_nonchat_nongroup() {
check_alias_reply(false, false, false).await;
async fn test_alias_support_answer_from_nondc() {
// Bob, the other supporter, answers with a classic MUA.
let bob_answer = b"To: support@example.org, claire@example.org\n\
From: bob@example.net\n\
Subject: =?utf-8?q?Re=3A_i_have_a_question?=\n\
References: <non-dc-1@example.org>\n\
In-Reply-To: <non-dc-1@example.org>\n\
Message-ID: <non-dc-2@example.net>\n\
Date: Sun, 14 Mar 2021 16:04:57 +0000\n\
Content-Type: text/plain\n\
\n\
hi claire, the version is 1.0, cheers bob";
check_alias_reply(bob_answer, true, true).await;
check_alias_reply(bob_answer, false, true).await;
check_alias_reply(bob_answer, true, false).await;
check_alias_reply(bob_answer, false, false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_nondc_nonchat_group() {
check_alias_reply(false, false, true).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_nondc_chat_nongroup() {
check_alias_reply(false, true, false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_nondc_chat_group() {
check_alias_reply(false, true, true).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_dc_nonchat_nongroup() {
check_alias_reply(true, false, false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_dc_nonchat_group() {
check_alias_reply(true, false, true).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_dc_chat_nongroup() {
check_alias_reply(true, true, false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_alias_support_answer_from_dc_chat_group() {
check_alias_reply(true, true, true).await;
async fn test_alias_answer_from_dc() {
// Bob, the other supporter, answers with Delta Chat.
let bob_answer = b"To: support@example.org, claire@example.org\n\
From: bob@example.net\n\
Subject: =?utf-8?q?Re=3A_i_have_a_question?=\n\
References: <Gr.af9e810c9b592927.gNm8dVdkZsH@example.net>\n\
In-Reply-To: <non-dc-1@example.org>\n\
Message-ID: <Gr.af9e810c9b592927.gNm8dVdkZsH@example.net>\n\
Date: Sun, 14 Mar 2021 16:04:57 +0000\n\
Chat-Version: 1.0\n\
Chat-Group-ID: af9e810c9b592927\n\
Chat-Group-Name: =?utf-8?q?i_have_a_question?=\n\
Chat-Disposition-Notification-To: bob@example.net\n\
Content-Type: text/plain\n\
\n\
hi claire, the version is 1.0, cheers bob";
check_alias_reply(bob_answer, true, true).await;
check_alias_reply(bob_answer, false, true).await;
check_alias_reply(bob_answer, true, false).await;
check_alias_reply(bob_answer, false, false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -3240,29 +3213,6 @@ async fn test_auto_accept_group_for_bots() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_as_bot() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::Bot, Some("1")).await.unwrap();
let bob = &tcm.bob().await;
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
let alice_bob_id = Contact::create(alice, "", &bob_addr).await?;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let alice_chat_id = ChatId::lookup_by_contact(alice, alice_bob_id)
.await?
.unwrap();
let msg = alice.get_last_msg_in(alice_chat_id).await;
assert!(msg.is_bot());
let msg = bob.get_last_msg_in(bob_chat_id).await;
assert!(msg.is_bot());
chat::forward_msgs(bob, &[msg.id], bob_chat_id).await?;
let msg = bob.get_last_msg_in(bob_chat_id).await;
assert!(msg.is_forwarded());
assert!(!msg.is_bot());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_private_reply_to_blocked_account() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -4430,7 +4380,7 @@ Chat-Group-Member-Added: charlie@example.com",
let contacts = get_chat_contacts(&bob, bob_chat_id).await?;
assert_eq!(contacts.len(), 3);
// Bob fully receives the partial message.
// Bob fully reives the partial message.
let msg_id = receive_imf_from_inbox(
&bob,
"first@example.org",
@@ -4620,83 +4570,3 @@ async fn test_receive_vcard() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_group_no_recipients() -> Result<()> {
let t = &TestContext::new_alice().await;
let raw = b"From: alice@example.org\n\
Subject: Group\n\
Chat-Version: 1.0\n\
Chat-Group-Name: Group\n\
Chat-Group-ID: GePFDkwEj2K\n\
Message-ID: <foobar@localhost>\n\
\n\
Hello!";
let received = receive_imf(t, raw, false).await?.unwrap();
let msg = Message::load_from_db(t, *received.msg_ids.last().unwrap()).await?;
let chat = Chat::load_from_db(t, msg.chat_id).await?;
assert_eq!(chat.typ, Chattype::Group);
Ok(())
}
/// Tests that creating a group
/// is preferred over assigning message to existing
/// chat based on `In-Reply-To` and `References`.
///
/// Referenced message itself may be incorrectly assigned,
/// but `Chat-Group-ID` uniquely identifies the chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_prefer_chat_group_id_over_references() -> Result<()> {
let t = &TestContext::new_alice().await;
// Alice receives 1:1 message from Bob.
let raw = b"From: bob@example.net\n\
To: alice@example.org\n\
Subject: Hi\n\
Message-ID: <oneoneone@localhost>\n\
\n\
Hello!";
receive_imf(t, raw, false).await?.unwrap();
// Alice receives a group message from Bob.
// This references 1:1 message,
// but should create a group.
let raw = b"From: bob@example.net\n\
To: alice@example.org\n\
Subject: Group\n\
Chat-Version: 1.0\n\
Chat-Group-Name: Group 1\n\
Chat-Group-ID: GePFDkwEj2K\n\
Message-ID: <incoming@localhost>\n\
References: <oneoneone@localhost>\n\
In-Reply-To: <oneoneone@localhost>\n\
\n\
Group 1";
let received1 = receive_imf(t, raw, false).await?.unwrap();
let msg1 = Message::load_from_db(t, *received1.msg_ids.last().unwrap()).await?;
let chat1 = Chat::load_from_db(t, msg1.chat_id).await?;
assert_eq!(chat1.typ, Chattype::Group);
// Alice receives outgoing group message.
// This references 1:1 message,
// but should create another group.
let raw = b"From: alice@example.org\n\
To: bob@example.net
Subject: Group\n\
Chat-Version: 1.0\n\
Chat-Group-Name: Group 2\n\
Chat-Group-ID: Abcdexyzfoo\n\
Message-ID: <outgoing@localhost>\n\
References: <oneoneone@localhost>\n\
In-Reply-To: <oneoneone@localhost>\n\
\n\
Group 2";
let received2 = receive_imf(t, raw, false).await?.unwrap();
let msg2 = Message::load_from_db(t, *received2.msg_ids.last().unwrap()).await?;
let chat2 = Chat::load_from_db(t, msg2.chat_id).await?;
assert_eq!(chat2.typ, Chattype::Group);
assert_ne!(chat1.id, chat2.id);
Ok(())
}

View File

@@ -830,26 +830,20 @@ async fn send_mdn(context: &Context, smtp: &mut Smtp) -> Result<bool> {
.await
.context("failed to update MDN retries count")?;
match send_mdn_msg_id(context, msg_id, contact_id, smtp).await {
Err(err) => {
// If there is an error, for example there is no message corresponding to the msg_id in the
// database, do not try to send this MDN again.
warn!(
context,
"Error sending MDN for {msg_id}, removing it: {err:#}."
);
context
.sql
.execute("DELETE FROM smtp_mdns WHERE msg_id = ?", (msg_id,))
.await?;
Err(err)
}
Ok(false) => {
bail!("Temporary error while sending an MDN");
}
Ok(true) => {
// Successfully sent MDN.
Ok(true)
}
let res = send_mdn_msg_id(context, msg_id, contact_id, smtp).await;
if let Err(ref err) = res {
// If there is an error, for example there is no message corresponding to the msg_id in the
// database, do not try to send this MDN again.
warn!(
context,
"Error sending MDN for {msg_id}, removing it: {err:#}."
);
context
.sql
.execute("DELETE FROM smtp_mdns WHERE msg_id = ?", (msg_id,))
.await?;
}
// If there's a temporary error, pretend there are no more MDNs to send. It's unlikely that
// other MDNs could be sent successfully in case of connectivity problems.
res
}

View File

@@ -5,7 +5,7 @@ use deltachat_contact_tools::EmailAddress;
use rusqlite::OptionalExtension;
use crate::config::Config;
use crate::constants::ShowEmails;
use crate::constants::{self, ShowEmails};
use crate::context::Context;
use crate::imap;
use crate::message::MsgId;
@@ -839,7 +839,11 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
if dbversion < 108 {
let version = 108;
let chunk_size = context.get_max_smtp_rcpt_to().await?;
let chunk_size = context
.get_configured_provider()
.await?
.and_then(|provider| provider.opt.max_smtp_rcpt_to)
.map_or(constants::DEFAULT_MAX_SMTP_RCPT_TO, usize::from);
sql.transaction(move |trans| {
Sql::set_db_version_trans(trans, version)?;
let id_max =
@@ -932,11 +936,6 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
.await?;
}
if dbversion < 114 {
sql.execute_migration("CREATE INDEX reactions_index1 ON reactions (msg_id)", 114)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -193,18 +193,7 @@ impl TestContextManager {
#[derive(Debug, Clone, Default)]
pub struct TestContextBuilder {
key_pair: Option<KeyPair>,
/// Log sink if set.
///
/// If log sink is not set,
/// a new one will be created and stored
/// inside the test context when it is built.
/// If log sink is provided by the caller,
/// it will be subscribed to the test context,
/// but not stored inside of it,
/// so the caller should store the LogSink elsewhere to
/// prevent it from being dropped immediately.
log_sink: Option<LogSink>,
log_sink: LogSink,
}
impl TestContextBuilder {
@@ -245,7 +234,7 @@ impl TestContextBuilder {
/// sequence as they occurred rather than all messages from each context in a single
/// block.
pub fn with_log_sink(mut self, sink: LogSink) -> Self {
self.log_sink = Some(sink);
self.log_sink = sink;
self
}
@@ -253,7 +242,7 @@ impl TestContextBuilder {
pub async fn build(self) -> TestContext {
let name = self.key_pair.as_ref().map(|key| key.addr.local.clone());
let test_context = TestContext::new_internal(name, self.log_sink).await;
let test_context = TestContext::new_internal(name, Some(self.log_sink.clone())).await;
if let Some(key_pair) = self.key_pair {
test_context

View File

@@ -327,12 +327,6 @@ async fn check_no_transition_done(groups: &[ChatId], old_alice_addr: &str, bob:
last_info_msg.is_none(),
"{last_info_msg:?} shouldn't be there (or it's an unrelated info msg)"
);
let sent = bob.send_text(*group, "hi").await;
let msg = Message::load_from_db(bob, sent.sender_msg_id)
.await
.unwrap();
assert_eq!(msg.get_showpadlock(), true);
}
}

View File

@@ -25,7 +25,6 @@ Messenger functions | [Chat-over-Email](https://github.com/deltacha
Detect mailing list | List-Id ([RFC 2919][]) and Precedence ([RFC 3834][])
User and chat colors | [XEP-0392][]: Consistent Color Generation
Send and receive system messages | Multipart/Report Media Type ([RFC 6522][])
Send and receive contact files | vCard ([RFC 6350][])
Return receipts | Message Disposition Notification (MDN, [RFC 8098][], [RFC 3503][]) using the Chat-Disposition-Notification-To header
Locations | KML ([Open Geospatial Consortium](http://www.opengeospatial.org/standards/kml/), [Google Dev](https://developers.google.com/kml/))
@@ -54,7 +53,6 @@ Locations | KML ([Open Geospatial Consortium](http://www.
[RFC 5321]: https://tools.ietf.org/html/rfc5321
[RFC 5322]: https://tools.ietf.org/html/rfc5322
[RFC 6154]: https://tools.ietf.org/html/rfc6154
[RFC 6350]: https://tools.ietf.org/html/rfc6350
[RFC 6522]: https://tools.ietf.org/html/rfc6522
[RFC 6749]: https://tools.ietf.org/html/rfc6749
[RFC 7162]: https://tools.ietf.org/html/rfc7162