Compare commits

..

2 Commits

Author SHA1 Message Date
iequidoo
99c2febf13 feat: Disable wal_autocheckpoint
From https://www.sqlite.org/wal.html:
> The default strategy is to allow successive write transactions to grow the WAL until the WAL
  becomes about 1000 pages in size, then to run a checkpoint operation for each subsequent COMMIT
  until the WAL is reset to be smaller than 1000 pages. By default, the checkpoint will be run
  automatically by the same thread that does the COMMIT that pushes the WAL over its size
  limit. This has the effect of causing most COMMIT operations to be very fast but an occasional
  COMMIT (those that trigger a checkpoint) to be much slower.

And while autocheckpoint runs in the `PASSIVE` mode and thus doesn't block concurrent readers and
writers, in our design it blocks writers because it's done under `write_mutex` locked and thus may
cause the app to stuck for noticeable time. Let's disable autocheckpointing then, we can't rely on
it anyway. Instead, run a `TRUNCATE` checkpoint from `inbox_loop()` if the WAL is >= 4K pages and a
`PASSIVE` checkpoint otherwise.
2026-04-21 02:58:12 -03:00
iequidoo
76b51ff8da feat: Trigger housekeeping after opening db
Some migrations want housekeeping to run. Also if housekeeping failed before, fixing the reason and
restarting the program is the most natural way to retry it.
2026-04-19 23:38:18 -03:00
399 changed files with 4857 additions and 1255 deletions

View File

@@ -23,7 +23,7 @@ env:
RUST_VERSION: 1.95.0
# Minimum Supported Rust Version
MSRV: 1.89.0
MSRV: 1.88.0
jobs:
lint_rust:
@@ -41,8 +41,6 @@ jobs:
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run rustfmt
run: cargo fmt --all -- --check
- name: Run clippy
@@ -94,8 +92,6 @@ jobs:
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Rustdoc
run: cargo doc --document-private-items --no-deps
@@ -139,11 +135,9 @@ jobs:
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install nextest
uses: taiki-e/install-action@5f57d6cb7cd20b14a8a27f522884c4bc8a187458
uses: taiki-e/install-action@85b24a67ef0c632dfefad70b9d5ce8fddb040754
with:
tool: nextest
@@ -175,8 +169,6 @@ jobs:
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build C library
run: cargo build -p deltachat_ffi
@@ -203,8 +195,6 @@ jobs:
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build deltachat-rpc-server
run: cargo build -p deltachat-rpc-server

View File

@@ -34,7 +34,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -58,7 +58,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
@@ -82,7 +82,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -106,7 +106,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
@@ -157,7 +157,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -181,7 +181,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
@@ -208,7 +208,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v7
@@ -382,7 +382,7 @@ jobs:
- name: Publish deltachat-rpc-server to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
publish_npm_package:
name: Build & Publish npm prebuilds and deltachat-rpc-server

View File

@@ -25,7 +25,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix fmt flake.nix -- --check
build:
@@ -84,7 +84,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -105,5 +105,5 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix build .#${{ matrix.installable }}

View File

@@ -47,4 +47,4 @@ jobs:
name: python-package-distributions
path: dist/
- name: Publish deltachat-rpc-client to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e

View File

@@ -18,7 +18,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary

View File

@@ -41,7 +41,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -63,7 +63,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat

View File

@@ -23,4 +23,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

95
Cargo.lock generated
View File

@@ -36,7 +36,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
"cpufeatures",
]
[[package]]
@@ -136,7 +136,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"cpufeatures",
"password-hash",
"zeroize",
]
@@ -194,9 +194,9 @@ dependencies = [
[[package]]
name = "astral-tokio-tar"
version = "0.6.1"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693"
checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9"
dependencies = [
"filetime",
"futures-core",
@@ -497,16 +497,16 @@ dependencies = [
[[package]]
name = "blake3"
version = "1.8.5"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq 0.4.2",
"cpufeatures 0.3.0",
"cpufeatures",
]
[[package]]
@@ -799,7 +799,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
"cpufeatures",
]
[[package]]
@@ -934,9 +934,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorutils-rs"
version = "0.8.0"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69abc9a8ed011e2b7946769f460b9e76e8b659ece9ef4001b9d8bba3489f796d"
checksum = "6e2fc25857fa523662de5cae84225b0e7bfb24a2a3f9ed8802fecf03df7252b1"
dependencies = [
"erydanos",
"half",
@@ -1011,15 +1011,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.2.1"
@@ -1225,7 +1216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
@@ -1301,9 +1292,9 @@ dependencies = [
[[package]]
name = "data-encoding"
version = "2.11.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dbl"
@@ -2610,9 +2601,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
@@ -2625,6 +2616,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -2662,7 +2654,7 @@ dependencies = [
"hyper",
"libc",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.6.0",
"tokio",
"tower-service",
"tracing",
@@ -3254,7 +3246,7 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures 0.2.17",
"cpufeatures",
]
[[package]]
@@ -3268,9 +3260,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libm"
@@ -3461,13 +3453,13 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.61.1",
"windows-sys 0.52.0",
]
[[package]]
@@ -3941,14 +3933,15 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.79"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -3981,9 +3974,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
version = "0.9.115"
version = "0.9.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07"
dependencies = [
"cc",
"libc",
@@ -4419,7 +4412,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
@@ -4431,7 +4424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
@@ -5171,7 +5164,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki 0.103.12",
"subtle",
"zeroize",
]
@@ -5208,9 +5201,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"ring",
"rustls-pki-types",
@@ -5515,7 +5508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -5526,7 +5519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -5554,7 +5547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -5732,12 +5725,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys 0.61.1",
"windows-sys 0.59.0",
]
[[package]]
@@ -6151,9 +6144,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -6161,7 +6154,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.61.1",
]
@@ -6178,9 +6171,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -3,7 +3,7 @@ name = "deltachat"
version = "2.50.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
rust-version = "1.88"
repository = "https://github.com/chatmail/core"
[profile.dev]
@@ -53,7 +53,7 @@ blake3 = "1.8.2"
brotli = { version = "8", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.8.0", default-features = false }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "1"
@@ -103,7 +103,7 @@ thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.6.1", default-features = false }
astral-tokio-tar = { version = "0.6", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"

View File

@@ -161,16 +161,3 @@ are documented.
Follow Rust guidelines for the documentation comments:
<https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#summary-sentence>
## Do not use `into()`, `try_into()` or `parse()`
For internal types, implementing `From`, `TryFrom` or `FromStr` is discouraged.
Instead, a `new()` function is recommended.
For external types, prefer using `Type::from()`, `Type::try_from()` or `Type::from_str()`
over `into()`, `try_into()` or `parse()`.
Calling `into()`, `try_into()` or `parse()`
creates an indirection,
which is hard to follow for people who are not familiar with Rust,
or who are not using rust-analyzer.

View File

@@ -390,9 +390,27 @@ char* dc_get_blobdir (const dc_context_t* context);
/**
* Configure the context. The configuration is handled by key=value pairs as:
*
* - `configured_addr` = Email address in use.
* - `addr` = Email address to use for configuration.
* If dc_configure() fails this is not the email address actually in use.
* Use `configured_addr` to find out the email address actually in use.
* - `configured_addr` = Email address actually in use.
* Unless for testing, do not set this value using dc_set_config().
* Instead, set `addr` and call dc_configure().
* - `mail_server` = IMAP-server, guessed if left out
* - `mail_user` = IMAP-username, guessed if left out
* - `mail_pw` = IMAP-password (always needed)
* - `mail_port` = IMAP-port, guessed if left out
* - `mail_security`= IMAP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `send_server` = SMTP-server, guessed if left out
* - `send_user` = SMTP-user, guessed if left out
* - `send_pw` = SMTP-password, guessed if left out
* - `send_port` = SMTP-port, guessed if left out
* - `send_security`= SMTP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `server_flags` = IMAP-/SMTP-flags as a combination of @ref DC_LP flags, guessed if left out
* - `proxy_enabled` = Proxy enabled. Disabled by default.
* - `proxy_url` = Proxy URL. May contain multiple URLs separated by newline, but only the first one is used.
* - `imap_certificate_checks` = how to check IMAP certificates, one of the @ref DC_CERTCK flags, defaults to #DC_CERTCK_AUTO (0)
* - `smtp_certificate_checks` = deprecated option, should be set to the same value as `imap_certificate_checks` but ignored by the new core
* - `displayname` = Own name to use when sending messages. MUAs are allowed to spread this way e.g. using CC, defaults to empty
* - `selfstatus` = Own status to display, e.g. in e-mail footers, defaults to empty
* - `selfavatar` = File containing avatar. Will immediately be copied to the
@@ -408,6 +426,12 @@ char* dc_get_blobdir (const dc_context_t* context);
* 1=send a copy of outgoing messages to self (default).
* Sending messages to self is needed for a proper multi-account setup,
* however, on the other hand, may lead to unwanted notifications in non-delta clients.
* - `show_emails` = DC_SHOW_EMAILS_OFF (0)=
* show direct replies to chats only,
* DC_SHOW_EMAILS_ACCEPTED_CONTACTS (1)=
* also show all mails of confirmed contacts,
* DC_SHOW_EMAILS_ALL (2)=
* also show mails of unconfirmed contacts (default).
* - `delete_device_after` = 0=do not delete messages from device automatically (default),
* >=1=seconds, after which messages are deleted automatically from the device.
* Messages in the "saved messages" chat (see dc_chat_is_self_talk()) are skipped.
@@ -416,7 +440,8 @@ char* dc_get_blobdir (const dc_context_t* context);
* - `delete_server_after` = 0=do not delete messages from server automatically (default),
* 1=delete messages directly after receiving from server, mvbox is skipped.
* >1=seconds, after which messages are deleted automatically from the server, mvbox is used as defined.
* "Saved messages" are deleted from the server as well as emails, the UI should clearly point that out.
* "Saved messages" are deleted from the server as well as
* e-mails matching the `show_emails` settings above, the UI should clearly point that out.
* See also dc_estimate_deletion_cnt().
* - `media_quality` = DC_MEDIA_QUALITY_BALANCED (0) =
* good outgoing images/videos/voice quality at reasonable sizes (default)
@@ -488,27 +513,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* 1 = Contacts (default, does not include contact requests),
* 2 = Nobody (calls never result in a notification).
*
* Also, there are configs that are only needed
* if you want to use the deprecated dc_configure() API, such as:
*
* - `addr` = Email address to use for configuration.
* If dc_configure() fails this is not the email address actually in use.
* Use `configured_addr` to find out the email address actually in use.
* - `mail_server` = IMAP-server, guessed if left out
* - `mail_user` = IMAP-username, guessed if left out
* - `mail_pw` = IMAP-password (always needed)
* - `mail_port` = IMAP-port, guessed if left out
* - `mail_security`= IMAP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `send_server` = SMTP-server, guessed if left out
* - `send_user` = SMTP-user, guessed if left out
* - `send_pw` = SMTP-password, guessed if left out
* - `send_port` = SMTP-port, guessed if left out
* - `send_security`= SMTP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `server_flags` = IMAP-/SMTP-flags as a combination of @ref DC_LP flags, guessed if left out
* - `proxy_enabled` = Proxy enabled. Disabled by default.
* - `proxy_url` = Proxy URL. May contain multiple URLs separated by newline, but only the first one is used.
* - `imap_certificate_checks` = how to check IMAP and SMTP certificates, one of the @ref DC_CERTCK flags, defaults to #DC_CERTCK_AUTO (0)
* If you want to retrieve a value, use dc_get_config().
*
* @memberof dc_context_t
@@ -534,6 +538,9 @@ int dc_set_config (dc_context_t* context, const char*
* an error (no warning as it should be shown to the user) is logged but the attachment is sent anyway.
* - `sys.config_keys` = get a space-separated list of all config-keys available.
* The config-keys are the keys that can be passed to the parameter `key` of this function.
* - `quota_exceeding` = 0: quota is unknown or in normal range;
* >=80: quota is about to exceed, the value is the concrete percentage,
* a device message is added when that happens, however, that value may still be interesting for bots.
*
* @memberof dc_context_t
* @param context The context object. For querying system values, this can be NULL.
@@ -692,12 +699,6 @@ int dc_get_push_state (dc_context_t* context);
/**
* Configure a context.
*
* This way of configuring a context is deprecated,
* and does not allow to configure multiple transports.
* If you can, use the JSON-RPC API (../deltachat-jsonrpc/src/api.rs)
* `add_or_update_transport()`/`addOrUpdateTransport()` instead.
*
* During configuration IO must not be started,
* if needed stop IO using dc_accounts_stop_io() or dc_stop_io() first.
* If the context is already configured,
@@ -1387,6 +1388,7 @@ dc_msg_t* dc_get_draft (dc_context_t* context, uint32_t ch
#define DC_GCM_ADDDAYMARKER 0x01
#define DC_GCM_INFO_ONLY 0x02
/**
@@ -1407,6 +1409,7 @@ dc_msg_t* dc_get_draft (dc_context_t* context, uint32_t ch
* @param flags If set to DC_GCM_ADDDAYMARKER, the marker DC_MSG_ID_DAYMARKER will
* be added before each day (regarding the local timezone). Set this to 0 if you do not want this behaviour.
* The day marker timestamp is the midnight one for the corresponding (following) day in the local timezone.
* If set to DC_GCM_INFO_ONLY, only system messages will be returned, can be combined with DC_GCM_ADDDAYMARKER.
* @param marker1before Deprecated, set this to 0.
* @return Array of message IDs, must be dc_array_unref()'d when no longer used.
*/
@@ -1470,6 +1473,7 @@ dc_chatlist_t* dc_get_similar_chatlist (dc_context_t* context, uint32_t ch
* @param from_server 1=Estimate deletion count for server, 0=Estimate deletion count for device
* @param seconds Count messages older than the given number of seconds.
* @return Number of messages that are older than the given number of seconds.
* This includes e-mails downloaded due to the `show_emails` option.
* Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
*/
int dc_estimate_deletion_cnt (dc_context_t* context, int from_server, int64_t seconds);
@@ -2815,6 +2819,19 @@ int dc_set_location (dc_context_t* context, double latit
dc_array_t* dc_get_locations (dc_context_t* context, uint32_t chat_id, uint32_t contact_id, int64_t timestamp_begin, int64_t timestamp_end);
/**
* Delete all locations on the current device.
* Locations already sent cannot be deleted.
*
* Typically results in the event #DC_EVENT_LOCATION_CHANGED
* with contact_id set to 0.
*
* @memberof dc_context_t
* @param context The context object.
*/
void dc_delete_all_locations (dc_context_t* context);
// misc
/**
@@ -4003,6 +4020,8 @@ int dc_msg_get_viewtype (const dc_msg_t* msg);
* Marked as read on IMAP and MDN may be sent. Use dc_markseen_msgs() to mark messages as being seen.
*
* Outgoing message states:
* - @ref DC_STATE_OUT_PREPARING - For files which need time to be prepared before they can be sent,
* the message enters this state before @ref DC_STATE_OUT_PENDING. Deprecated.
* - @ref DC_STATE_OUT_DRAFT - Message saved as draft using dc_set_draft()
* - @ref DC_STATE_OUT_PENDING - The user has pressed the "send" button but the
* message is not yet sent and is pending in some way. Maybe we're offline (no checkmark).
@@ -5587,6 +5606,13 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
*/
#define DC_STATE_IN_SEEN 16
/**
* Outgoing message being prepared. See dc_msg_get_state() for details.
*
* @deprecated 2024-12-07
*/
#define DC_STATE_OUT_PREPARING 18
/**
* Outgoing message drafted. See dc_msg_get_state() for details.
*/
@@ -5778,7 +5804,7 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
* These constants configure TLS certificate checks for IMAP and SMTP connections.
*
* These constants are set via dc_set_config()
* using key "imap_certificate_checks".
* using keys "imap_certificate_checks" and "smtp_certificate_checks".
*
* @addtogroup DC_CERTCK
* @{
@@ -6391,7 +6417,8 @@ void dc_event_unref(dc_event_t* event);
* Location of one or more contact has changed.
*
* @param data1 (int) contact_id of the contact for which the location has changed.
* If the locations of several contacts have been changed, this parameter is set to 0.
* If the locations of several contacts have been changed,
* e.g. after calling dc_delete_all_locations(), this parameter is set to 0.
* @param data2 0
*/
#define DC_EVENT_LOCATION_CHANGED 2035
@@ -6662,6 +6689,14 @@ void dc_event_unref(dc_event_t* event);
#define DC_EVENT_DATA2_IS_STRING(e) ((e)==DC_EVENT_CONFIGURE_PROGRESS || (e)==DC_EVENT_IMEX_FILE_WRITTEN || ((e)>=100 && (e)<=499))
/*
* Values for dc_get|set_config("show_emails")
*/
#define DC_SHOW_EMAILS_OFF 0
#define DC_SHOW_EMAILS_ACCEPTED_CONTACTS 1
#define DC_SHOW_EMAILS_ALL 2
/*
* Values for dc_get|set_config("media_quality")
*/
@@ -6996,7 +7031,11 @@ void dc_event_unref(dc_event_t* event);
/// Used in message summary text for notifications and chatlist.
#define DC_STR_FORWARDED 97
/// @deprecated 2026-04-25
/// "Quota exceeding, already %1$s%% used."
///
/// Used as device message text.
///
/// `%1$s` will be replaced by the percentage used
#define DC_STR_QUOTA_EXCEEDING_MSG_BODY 98
/// "Multi Device Synchronization"

View File

@@ -60,6 +60,7 @@ use self::string::*;
// - finally, this behaviour matches the old core-c API and UIs already depend on it
const DC_GCM_ADDDAYMARKER: u32 = 0x01;
const DC_GCM_INFO_ONLY: u32 = 0x02;
// dc_context_t
@@ -1337,13 +1338,17 @@ pub unsafe extern "C" fn dc_get_chat_msgs(
}
let ctx = &*context;
let info_only = (flags & DC_GCM_INFO_ONLY) != 0;
let add_daymarker = (flags & DC_GCM_ADDDAYMARKER) != 0;
block_on(async move {
Box::into_raw(Box::new(
chat::get_chat_msgs_ex(
ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await
.unwrap_or_log_default(ctx, "failed to get chat msgs")
@@ -2541,7 +2546,7 @@ pub unsafe extern "C" fn dc_send_locations_to_chat(
}
let ctx = &*context;
block_on(location::send_to_chat(
block_on(location::send_locations_to_chat(
ctx,
ChatId::new(chat_id),
seconds as i64,
@@ -2561,14 +2566,14 @@ pub unsafe extern "C" fn dc_is_sending_locations_to_chat(
return 0;
}
let ctx = &*context;
if chat_id == 0 {
block_on(location::is_sending(ctx))
.unwrap_or_log_default(ctx, "Failed is_sending_locations()") as libc::c_int
let chat_id = if chat_id == 0 {
None
} else {
block_on(location::is_sending_to_chat(ctx, ChatId::new(chat_id)))
.unwrap_or_log_default(ctx, "Failed is_sending_locations_to_chat()")
as libc::c_int
}
Some(ChatId::new(chat_id))
};
block_on(location::is_sending_locations_to_chat(ctx, chat_id))
.unwrap_or_log_default(ctx, "Failed dc_is_sending_locations_to_chat()") as libc::c_int
}
#[no_mangle]
@@ -2584,9 +2589,12 @@ pub unsafe extern "C" fn dc_set_location(
}
let ctx = &*context;
block_on(location::set(ctx, latitude, longitude, accuracy))
.log_err(ctx)
.unwrap_or_default() as libc::c_int
block_on(async move {
location::set(ctx, latitude, longitude, accuracy)
.await
.log_err(ctx)
.unwrap_or_default()
}) as libc::c_int
}
#[no_mangle]
@@ -2621,6 +2629,23 @@ pub unsafe extern "C" fn dc_get_locations(
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_delete_all_locations(context: *mut dc_context_t) {
if context.is_null() {
eprintln!("ignoring careless call to dc_delete_all_locations()");
return;
}
let ctx = &*context;
block_on(async move {
location::delete_all(ctx)
.await
.context("Failed to delete locations")
.log_err(ctx)
.ok()
});
}
#[no_mangle]
pub unsafe extern "C" fn dc_create_qr_svg(payload: *const libc::c_char) -> *mut libc::c_char {
if payload.is_null() {

View File

@@ -230,6 +230,7 @@ pub enum LotState {
MsgInFresh = 10,
MsgInNoticed = 13,
MsgInSeen = 16,
MsgOutPreparing = 18,
MsgOutDraft = 19,
MsgOutPending = 20,
MsgOutFailed = 24,
@@ -245,6 +246,7 @@ impl From<MessageState> for LotState {
InFresh => LotState::MsgInFresh,
InNoticed => LotState::MsgInNoticed,
InSeen => LotState::MsgInSeen,
OutPreparing => LotState::MsgOutPreparing,
OutDraft => LotState::MsgOutDraft,
OutPending => LotState::MsgOutPending,
OutFailed => LotState::MsgOutFailed,

View File

@@ -318,6 +318,15 @@ impl CommandApi {
Ok(())
}
/// Requests to clear storage on all chatmail relays.
///
/// I/O must be started for this request to take effect.
async fn clear_all_relay_storage(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.clear_all_relay_storage().await?;
Ok(())
}
/// Get top-level info for an account.
async fn get_account_info(&self, account_id: u32) -> Result<Account> {
let context_option = self.accounts.read().await.get_account(account_id);
@@ -1366,22 +1375,8 @@ impl CommandApi {
markseen_msgs(&ctx, msg_ids.into_iter().map(MsgId::new).collect()).await
}
/// Get all message IDs belonging to a chat.
/// Returns all messages of a particular chat.
///
/// The list is already sorted and starts with the oldest message.
/// Clients should not try to re-sort the list as this would be an expensive action
/// and would result in inconsistencies between clients.
/// Note that the messages are not necessarily sorted by their ID or by their displayed timestamp;
/// UIs need to handle both the case of descending message IDs
/// and of decreasing timestamps.
///
/// Optionally, 'daymarkers' added to the ID array may help to
/// implement virtual lists.
///
/// Parameters:
///
/// * chat_id The chat ID of which the messages IDs should be queried.
/// * _info_only: Deprecated, pass `false` here.
/// * `add_daymarker` - If `true`, add day markers as `DC_MSG_ID_DAYMARKER` to the result,
/// e.g. [1234, 1237, 9, 1239]. The day marker timestamp is the midnight one for the
/// corresponding (following) day in the local timezone.
@@ -1389,14 +1384,17 @@ impl CommandApi {
&self,
account_id: u32,
chat_id: u32,
_info_only: bool,
info_only: bool,
add_daymarker: bool,
) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ex(
&ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await?;
Ok(msg
@@ -1428,24 +1426,21 @@ impl CommandApi {
}
}
/// Get all messages belonging to a chat.
///
/// Similar to `get_message_ids` / `getMessageIds`,
/// see that function for details.
/// The difference is that this function here returns a list of `MessageListItem`,
/// which is an enum of a message or a daymarker.
async fn get_message_list_items(
&self,
account_id: u32,
chat_id: u32,
_info_only: bool,
info_only: bool,
add_daymarker: bool,
) -> Result<Vec<JsonrpcMessageListItem>> {
let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ex(
&ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await?;
Ok(msg
@@ -1882,6 +1877,20 @@ impl CommandApi {
deltachat::contact::make_vcard(&ctx, &contacts).await
}
/// Sets vCard containing the given contacts to the message draft.
async fn set_draft_vcard(
&self,
account_id: u32,
msg_id: u32,
contacts: Vec<u32>,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let contacts: Vec<_> = contacts.iter().map(|&c| ContactId::new(c)).collect();
let mut msg = Message::load_from_db(&ctx, MsgId::new(msg_id)).await?;
msg.make_vcard(&ctx, &contacts).await?;
msg.get_chat_id().set_draft(&ctx, Some(&mut msg)).await
}
// ---------------------------------------------
// chat
// ---------------------------------------------
@@ -2106,21 +2115,6 @@ impl CommandApi {
// locations
// ---------------------------------------------
/// Sets current location.
///
/// Returns true if location streaming is currently
/// enabled and locations should be updated.
///
/// Location is represented as latitude and longitude in degrees
/// and horizontal accuracy in meters.
async fn set_location(&self, latitude: f64, longitude: f64, accuracy: f64) -> Result<bool> {
self.accounts
.read()
.await
.set_location(latitude, longitude, accuracy)
.await
}
async fn get_locations(
&self,
account_id: u32,
@@ -2143,39 +2137,6 @@ impl CommandApi {
Ok(locations.into_iter().map(|l| l.into()).collect())
}
/// Enables location streaming in chat identified by `chat_id` for `seconds` seconds.
///
/// Pass 0 as the number of seconds to disable location streaming in the chat.
async fn send_locations_to_chat(
&self,
account_id: u32,
chat_id: u32,
seconds: i64,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let chat_id = ChatId::new(chat_id);
location::send_to_chat(&ctx, chat_id, seconds).await?;
Ok(())
}
/// Returns whether any chat is sending locations.
async fn is_sending_locations(&self, account_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
location::is_sending(&ctx).await
}
/// Returns whether `chat_id` is sending locations.
async fn is_sending_locations_to_chat(&self, account_id: u32, chat_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
let chat_id = ChatId::new(chat_id);
location::is_sending_to_chat(&ctx, chat_id).await
}
/// Stops sending locations to all chats.
async fn stop_sending_locations(&self) -> Result<()> {
self.accounts.read().await.stop_sending_locations().await
}
// ---------------------------------------------
// webxdc
// ---------------------------------------------
@@ -2407,7 +2368,6 @@ impl CommandApi {
chat::resend_msgs(&ctx, &message_ids).await
}
/// @deprecated as of 2026-04; use `send_msg` with `Viewtype::Sticker` instead.
async fn send_sticker(
&self,
account_id: u32,
@@ -2419,6 +2379,9 @@ impl CommandApi {
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(&ctx, Path::new(&sticker_path), None, None)?;
// JSON-rpc does not need heuristics to turn [Viewtype::Sticker] into [Viewtype::Image]
msg.force_sticker();
let message_id = deltachat::chat::send_msg(&ctx, ChatId::new(chat_id), &mut msg).await?;
Ok(message_id.to_u32())
}

View File

@@ -287,6 +287,8 @@ pub enum MessageViewtype {
Gif,
/// Message containing a sticker, similar to image.
/// NB: When sending, the message viewtype may be changed to `Image` by some heuristics like
/// checking for transparent pixels. Use `Message::force_sticker()` to disable them.
///
/// If possible, the ui should display the image without borders in a transparent way.
/// A click on a sticker will offer to install the sticker set in some future.

View File

@@ -238,7 +238,7 @@ impl From<Qr> for QrObject {
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskVerifyContact {
contact_id,
fingerprint,
@@ -257,7 +257,7 @@ impl From<Qr> for QrObject {
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskVerifyGroup {
grpname,
grpid,
@@ -278,7 +278,7 @@ impl From<Qr> for QrObject {
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskJoinBroadcast {
name,
grpid,
@@ -321,7 +321,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyContact {
contact_id,
fingerprint,
@@ -338,7 +338,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyGroup {
grpname,
grpid,
@@ -357,7 +357,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
@@ -374,7 +374,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyContact {
contact_id,
fingerprint,
@@ -391,7 +391,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyGroup {
grpname,
grpid,
@@ -410,7 +410,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveJoinBroadcast {
name,
grpid,

View File

@@ -85,7 +85,7 @@ mod tests {
assert_eq!(result, response.to_owned());
}
{
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":""}]}"#;
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":"","smtp_certificate_checks":""}]}"#;
let response = r#"{"jsonrpc":"2.0","id":2,"result":null}"#;
session.handle_incoming(request).await;
let result = receiver.recv().await?;

View File

@@ -345,6 +345,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
chatinfo\n\
sendlocations <seconds>\n\
setlocation <lat> <lng>\n\
dellocations\n\
getlocations [<contact-id>]\n\
send <text>\n\
send-sync <text>\n\
@@ -573,7 +574,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
);
}
}
if location::is_sending(&context).await? {
if location::is_sending_locations_to_chat(&context, None).await? {
println!("Location streaming enabled.");
}
println!("{cnt} chats");
@@ -622,6 +623,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
&context,
sel_chat.get_id(),
chat::MessageListOptions {
info_only: false,
add_daymarker: true,
},
)
@@ -780,7 +782,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!(
"Location streaming: {}",
location::is_sending_to_chat(&context, sel_chat.as_ref().unwrap().get_id()).await?,
location::is_sending_locations_to_chat(
&context,
Some(sel_chat.as_ref().unwrap().get_id())
)
.await?,
);
}
"getlocations" => {
@@ -820,7 +826,12 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ensure!(!arg1.is_empty(), "No timeout given.");
let seconds = arg1.parse()?;
location::send_to_chat(&context, sel_chat.as_ref().unwrap().get_id(), seconds).await?;
location::send_locations_to_chat(
&context,
sel_chat.as_ref().unwrap().get_id(),
seconds,
)
.await?;
println!(
"Locations will be sent to Chat#{} for {} seconds. Use 'setlocation <lat> <lng>' to play around.",
sel_chat.as_ref().unwrap().get_id(),
@@ -842,6 +853,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Success, streaming can be stopped.");
}
}
"dellocations" => {
location::delete_all(&context).await?;
}
"send" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "No message text given.");

View File

@@ -176,7 +176,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 39] = [
const CHAT_COMMANDS: [&str; 40] = [
"listchats",
"listarchived",
"start-realtime",
@@ -194,6 +194,7 @@ const CHAT_COMMANDS: [&str; 39] = [
"chatinfo",
"sendlocations",
"setlocation",
"dellocations",
"getlocations",
"send",
"send-sync",

View File

@@ -13,7 +13,7 @@ def main():
with Rpc() as rpc:
deltachat = DeltaChat(rpc)
system_info = deltachat.get_system_info()
logging.info(f"Running deltachat core {system_info['deltachat_core_version']}")
logging.info("Running deltachat core %s", system_info["deltachat_core_version"])
accounts = deltachat.get_all_accounts()
account = accounts[0] if accounts else deltachat.add_account()
@@ -21,30 +21,36 @@ def main():
account.set_config("bot", "1")
if not account.is_configured():
logging.info("Account is not configured, configuring")
account.add_or_update_transport({"addr": sys.argv[1], "password": sys.argv[2]})
account.set_config("addr", sys.argv[1])
account.set_config("mail_pw", sys.argv[2])
account.configure()
logging.info("Configured")
else:
logging.info("Account is already configured")
deltachat.start_io()
qr = account.get_qr_code()
logging.info(f"Invite link: {qr}")
while True:
event = account.wait_for_event()
if event.kind == EventType.INFO:
logging.info(event["msg"])
elif event.kind == EventType.WARNING:
logging.warning(event["msg"])
elif event.kind == EventType.ERROR:
logging.error(event["msg"])
elif event.kind == EventType.INCOMING_MSG:
logging.info("Got an incoming message")
message = account.get_message_by_id(event.msg_id)
def process_messages():
for message in account.get_next_messages():
snapshot = message.get_snapshot()
if snapshot.from_id != SpecialContactId.SELF and not snapshot.is_bot and not snapshot.is_info:
snapshot.chat.send_text(snapshot.text)
snapshot.message.mark_seen()
# Process old messages.
process_messages()
while True:
event = account.wait_for_event()
if event["kind"] == EventType.INFO:
logging.info("%s", event["msg"])
elif event["kind"] == EventType.WARNING:
logging.warning("%s", event["msg"])
elif event["kind"] == EventType.ERROR:
logging.error("%s", event["msg"])
elif event["kind"] == EventType.INCOMING_MSG:
logging.info("Got an incoming message")
process_messages()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
from warnings import warn
from ._utils import AttrDict, futuremethod
from .chat import Chat
@@ -391,7 +392,8 @@ class Account:
"""Return the list of fresh messages, newest messages first.
This call is intended for displaying notifications.
If you are writing a bot, process "incoming message" events instead.
If you are writing a bot, use `get_fresh_messages_in_arrival_order()` instead,
to process oldest messages first.
"""
fresh_msg_ids = self._rpc.get_fresh_msgs(self.id)
return [Message(self, msg_id) for msg_id in fresh_msg_ids]
@@ -461,6 +463,16 @@ class Account:
"""Wait for reaction change event."""
return self.wait_for_event(EventType.REACTIONS_CHANGED)
def get_fresh_messages_in_arrival_order(self) -> list[Message]:
"""Return fresh messages list sorted in the order of their arrival, with ascending IDs."""
warn(
"get_fresh_messages_in_arrival_order is deprecated, use get_next_messages instead.",
DeprecationWarning,
stacklevel=2,
)
fresh_msg_ids = sorted(self._rpc.get_fresh_msgs(self.id))
return [Message(self, msg_id) for msg_id in fresh_msg_ids]
def export_backup(self, path, passphrase: str = "") -> None:
"""Export backup."""
self._rpc.export_backup(self.id, str(path), passphrase)
@@ -483,7 +495,3 @@ class Account:
"""Return ICE servers for WebRTC configuration."""
ice_servers_json = self._rpc.ice_servers(self.id)
return json.loads(ice_servers_json)
def is_sending_locations(self) -> bool:
"""Return True if sending locations to any chat."""
return self._rpc.is_sending_locations(self.id)

View File

@@ -164,7 +164,7 @@ class Chat:
return Message(self.account, msg_id)
def send_sticker(self, path: str) -> Message:
"""Deprecated as of 2026-04; use `send_message` with `Viewtype.STICKER` instead."""
"""Send an sticker and return the resulting Message instance."""
msg_id = self._rpc.send_sticker(self.account.id, self.id, path)
return Message(self.account, msg_id)
@@ -206,9 +206,9 @@ class Chat:
snapshot["message"] = Message(self.account, snapshot.id)
return snapshot
def get_messages(self, add_daymarker: bool = False) -> list[Message]:
def get_messages(self, info_only: bool = False, add_daymarker: bool = False) -> list[Message]:
"""Get the list of messages in this chat."""
msgs = self._rpc.get_message_ids(self.account.id, self.id, False, add_daymarker)
msgs = self._rpc.get_message_ids(self.account.id, self.id, info_only, add_daymarker)
return [Message(self.account, msg_id) for msg_id in msgs]
def get_fresh_message_count(self) -> int:
@@ -277,16 +277,6 @@ class Chat:
"""Remove profile image of this chat."""
self._rpc.set_chat_profile_image(self.account.id, self.id, None)
def send_locations(self, seconds) -> None:
"""Enable location streaming in the chat for the given number of seconds.
Pass 0 to disable location streaming."""
self._rpc.send_locations_to_chat(self.account.id, self.id, seconds)
def is_sending_locations(self) -> bool:
"""Return True if sending locations to this chat."""
return self._rpc.is_sending_locations_to_chat(self.account.id, self.id)
def get_locations(
self,
contact: Optional[Contact] = None,

View File

@@ -190,6 +190,7 @@ class MessageState(IntEnum):
IN_FRESH = 10
IN_NOTICED = 13
IN_SEEN = 16
OUT_PREPARING = 18
OUT_DRAFT = 19
OUT_PENDING = 20
OUT_FAILED = 24

View File

@@ -59,11 +59,3 @@ class DeltaChat:
def set_translations(self, translations: dict[str, str]) -> None:
"""Set stock translation strings."""
self.rpc.set_stock_strings(translations)
def set_location(self, latitude, longitude, accuracy) -> bool:
"""Set location, return True if location streaming should continue."""
return self.rpc.set_location(latitude, longitude, accuracy)
def stop_sending_locations(self) -> None:
"""Stop sending locations to all chats."""
return self.rpc.stop_sending_locations()

View File

@@ -1,32 +0,0 @@
def test_set_location(dc, acfactory) -> None:
# Try setting location without any accounts.
assert not dc.set_location(1.0, 2.0, 0.1)
# Create one account that does not stream,
# set location.
acfactory.new_configured_account()
assert not dc.set_location(3.0, 4.0, 0.1)
def test_send_locations_to_chat(dc, acfactory):
alice, bob = acfactory.get_online_accounts(2)
assert not alice.is_sending_locations()
alice_chat_bob = alice.create_chat(bob)
assert not alice_chat_bob.is_sending_locations()
# Test starting and stopping location streaming in a chat.
alice_chat_bob.send_locations(3600)
assert alice.is_sending_locations()
assert alice_chat_bob.is_sending_locations()
alice_chat_bob.send_locations(0)
assert not alice.is_sending_locations()
assert not alice_chat_bob.is_sending_locations()
# Test stop_sending_locations() for all accounts and chats.
alice_chat_bob.send_locations(3600)
assert alice.is_sending_locations()
assert alice_chat_bob.is_sending_locations()
dc.stop_sending_locations()
assert not alice.is_sending_locations()
assert not alice_chat_bob.is_sending_locations()

View File

@@ -9,6 +9,8 @@ def test_add_second_address(acfactory) -> None:
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 2
@@ -26,6 +28,22 @@ def test_add_second_address(acfactory) -> None:
account.delete_transport(second_addr)
assert len(account.list_transports()) == 2
# show_emails does not matter for multi-relay, can be set to anything
account.set_config("show_emails", "0")
def test_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport can be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.set_config("show_emails", "0")
account.add_transport_from_qr(qr)
def test_change_address(acfactory) -> None:
"""Test Alice configuring a second transport and setting it as a primary one."""

View File

@@ -27,23 +27,7 @@ ignore = [
# <https://rustsec.org/advisories/RUSTSEC-2026-0099>
"RUSTSEC-2026-0049",
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
# Panic in CRL signature checks.
# We do not check CRL and cannot update rustls-webpki 0.102.8
# which is a dependency of iroh 0.35.0.
# <https://rustsec.org/advisories/RUSTSEC-2026-0104>
"RUSTSEC-2026-0104",
# hickory-proto 0.25.2 unbounded loop in DNSSEC code.
# Dependency of iroh 0.35.0, cannot be updated as of 2026-05-02.
# <https://rustsec.org/advisories/RUSTSEC-2026-0118>
"RUSTSEC-2026-0118",
# hickory-proto 0.25.2 quadratic complexity issue.
# Dependency of iroh 0.35.0, cannot be updated as of 2026-05-02.
# <https://rustsec.org/advisories/RUSTSEC-2026-0119>
"RUSTSEC-2026-0119"
"RUSTSEC-2026-0099"
]
[bans]
@@ -55,7 +39,6 @@ skip = [
{ name = "async-channel", version = "1.9.0" },
{ name = "bitflags", version = "1.3.2" },
{ name = "constant_time_eq", version = "0.3.1" },
{ name = "cpufeatures", version = "0.2.17" },
{ name = "derive_more-impl", version = "1.0.0" },
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },

View File

@@ -271,6 +271,15 @@ class Chat:
sent out. This is the same object as was passed in, which
has been modified with the new state of the core.
"""
if msg.is_out_preparing():
assert msg.id != 0
# get a fresh copy of dc_msg, the core needs it
maybe_msg = Message.from_db(self.account, msg.id)
if maybe_msg is not None:
msg = maybe_msg
else:
raise ValueError("message does not exist")
sent_id = lib.dc_send_msg(self.account._dc_context, self.id, msg._dc_msg)
if sent_id == 0:
raise ValueError("message could not be sent")
@@ -324,6 +333,26 @@ class Chat:
raise ValueError("message could not be sent")
return Message.from_db(self.account, sent_id)
def send_prepared(self, message):
"""send a previously prepared message.
:param message: a :class:`Message` instance previously returned by
:meth:`prepare_file`.
:raises ValueError: if message can not be sent.
:returns: a :class:`deltachat.message.Message` instance as sent out.
"""
assert message.id != 0 and message.is_out_preparing()
# get a fresh copy of dc_msg, the core needs it
msg = Message.from_db(self.account, message.id)
# pass 0 as chat-id because core-docs say it's ok when out-preparing
sent_id = lib.dc_send_msg(self.account._dc_context, 0, msg._dc_msg)
if sent_id == 0:
raise ValueError("message could not be sent")
assert sent_id == msg.id
# modify message in place to avoid bad state for the caller
msg._dc_msg = Message.from_db(self.account, sent_id)._dc_msg
def set_draft(self, message):
"""set message as draft.

View File

@@ -351,12 +351,17 @@ class Message:
def is_outgoing(self):
"""Return True if Message is outgoing."""
return lib.dc_msg_get_state(self._dc_msg) in (
const.DC_STATE_OUT_PREPARING,
const.DC_STATE_OUT_PENDING,
const.DC_STATE_OUT_FAILED,
const.DC_STATE_OUT_MDN_RCVD,
const.DC_STATE_OUT_DELIVERED,
)
def is_out_preparing(self):
"""Return True if Message is outgoing, but its file is being prepared."""
return self._msgstate == const.DC_STATE_OUT_PREPARING
def is_out_pending(self):
"""Return True if Message is outgoing, but is pending (no single checkmark)."""
return self._msgstate == const.DC_STATE_OUT_PENDING

View File

@@ -433,6 +433,7 @@ class ACFactory:
if self.pytestconfig.getoption("--strict-tls"):
# Enable strict certificate checks for online accounts
configdict["imap_certificate_checks"] = str(const.DC_CERTCK_STRICT)
configdict["smtp_certificate_checks"] = str(const.DC_CERTCK_STRICT)
assert "addr" in configdict and "mail_pw" in configdict
return configdict
@@ -504,6 +505,7 @@ class ACFactory:
"addr": cloned_from.get_config("addr"),
"mail_pw": cloned_from.get_config("mail_pw"),
"imap_certificate_checks": cloned_from.get_config("imap_certificate_checks"),
"smtp_certificate_checks": cloned_from.get_config("smtp_certificate_checks"),
}
configdict.update(kwargs)
ac = self._get_cached_account(addr=configdict["addr"]) if cache else None

View File

@@ -1,9 +1,3 @@
#!/bin/sh
# Run clippy for all Rust code in the project.
#
# To check, run
# scripts/clippy.sh
#
# To automatically fix warnings, run
# scripts/clippy.sh --fix --allow-dirty
cargo clippy --workspace --all-targets --all-features "$@" -- -D warnings
cargo clippy --workspace --all-targets --all-features -- -D warnings

View File

@@ -6,7 +6,7 @@ set -euo pipefail
export TZ=UTC
# Provider database revision.
REV=ad097ee40579c884e7757de2d3bb0a51f481a32a
REV=996c4bc82be5a7404f70b185ff062da33bfa98d9
CORE_ROOT="$PWD"
TMP="$(mktemp -d)"

View File

@@ -8,7 +8,6 @@ use std::sync::Arc;
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self, Receiver, Sender};
use futures::FutureExt as _;
use futures::future;
use futures_lite::FutureExt as _;
use serde::{Deserialize, Serialize};
use tokio::fs;
@@ -23,7 +22,6 @@ use tokio::time::{Duration, sleep};
use crate::context::{Context, ContextBuilder};
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::location;
use crate::log::warn;
use crate::push::PushSubscriber;
use crate::stock_str::StockStrings;
@@ -538,38 +536,6 @@ impl Accounts {
self.push_subscriber.set_device_token(token).await;
Ok(())
}
/// Sets location for all accounts.
///
/// Returns true if location should still be streamed.
pub async fn set_location(&self, latitude: f64, longitude: f64, accuracy: f64) -> Result<bool> {
let continue_streaming = future::try_join_all(self.accounts.iter().map(
|(account_id, account)| async move {
location::set(account, latitude, longitude, accuracy)
.await
.with_context(|| format!("Failed to set location for account {account_id}"))
},
))
.await?
.into_iter()
.any(|continue_streaming| continue_streaming);
Ok(continue_streaming)
}
/// Stops sending locations to all chats.
pub async fn stop_sending_locations(&self) -> Result<()> {
future::try_join_all(
self.accounts
.iter()
.map(|(account_id, account)| async move {
location::stop_sending(account).await.with_context(|| {
format!("Failed to stop sending locations for account {account_id}")
})
}),
)
.await?;
Ok(())
}
}
/// Configuration file name.

View File

@@ -4,8 +4,9 @@
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use anyhow::{Context as _, Result, bail};
use anyhow::{Context as _, Error, Result, bail};
use crate::key::{DcKey, SignedPublicKey};
@@ -27,8 +28,10 @@ impl fmt::Display for EncryptPreference {
}
}
impl EncryptPreference {
fn new(s: &str) -> Result<Self> {
impl FromStr for EncryptPreference {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"mutual" => Ok(EncryptPreference::Mutual),
"nopreference" => Ok(EncryptPreference::NoPreference),
@@ -82,8 +85,10 @@ impl fmt::Display for Aheader {
}
}
impl Aheader {
pub(crate) fn from_str(s: &str) -> Result<Self> {
impl FromStr for Aheader {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let mut attributes: BTreeMap<String, String> = s
.split(';')
.filter_map(|a| {
@@ -111,7 +116,7 @@ impl Aheader {
let prefer_encrypt = attributes
.remove("prefer-encrypt")
.and_then(|raw| EncryptPreference::new(&raw).ok())
.and_then(|raw| raw.parse().ok())
.unwrap_or_default();
let verified = attributes.remove("_verified").is_some();
@@ -139,9 +144,8 @@ mod tests {
#[test]
fn test_from_str() -> Result<()> {
let h = Aheader::from_str(&format!(
"addr=me@mail.com; prefer-encrypt=mutual; keydata={RAWKEY}"
))?;
let h: Aheader =
format!("addr=me@mail.com; prefer-encrypt=mutual; keydata={RAWKEY}").parse()?;
assert_eq!(h.addr, "me@mail.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual);
@@ -153,7 +157,7 @@ mod tests {
#[test]
fn test_from_str_reset() -> Result<()> {
let raw = format!("addr=reset@example.com; prefer-encrypt=reset; keydata={RAWKEY}");
let h = Aheader::from_str(&raw)?;
let h: Aheader = raw.parse()?;
assert_eq!(h.addr, "reset@example.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::NoPreference);
@@ -163,7 +167,7 @@ mod tests {
#[test]
fn test_from_str_non_critical() -> Result<()> {
let raw = format!("addr=me@mail.com; _foo=one; _bar=two; keydata={RAWKEY}");
let h = Aheader::from_str(&raw)?;
let h: Aheader = raw.parse()?;
assert_eq!(h.addr, "me@mail.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::NoPreference);
@@ -173,7 +177,7 @@ mod tests {
#[test]
fn test_from_str_superflous_critical() {
let raw = format!("addr=me@mail.com; _foo=one; _bar=two; other=me; keydata={RAWKEY}");
assert!(Aheader::from_str(&raw).is_err());
assert!(raw.parse::<Aheader>().is_err());
}
#[test]

561
src/authres.rs Normal file
View File

@@ -0,0 +1,561 @@
//! Parsing and handling of the Authentication-Results header.
//! See the comment on [`handle_authres`] for more.
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::fmt;
use std::sync::LazyLock;
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use mailparse::MailHeaderMap;
use mailparse::ParsedMail;
use crate::config::Config;
use crate::context::Context;
use crate::headerdef::HeaderDef;
/// `authres` is short for the Authentication-Results header, defined in
/// <https://datatracker.ietf.org/doc/html/rfc8601>, which contains info
/// about whether DKIM and SPF passed.
///
/// To mitigate From forgery, we remember for each sending domain whether it is known
/// to have valid DKIM. If an email from such a domain comes with invalid DKIM,
/// we don't allow changing the autocrypt key.
///
/// See <https://github.com/deltachat/deltachat-core-rust/issues/3507>.
pub(crate) async fn handle_authres(
context: &Context,
mail: &ParsedMail<'_>,
from: &str,
) -> Result<DkimResults> {
let from_domain = match EmailAddress::new(from) {
Ok(email) => email.domain,
Err(e) => {
return Err(anyhow::format_err!("invalid email {from}: {e:#}"));
}
};
let authres = parse_authres_headers(&mail.get_headers(), &from_domain);
update_authservid_candidates(context, &authres).await?;
compute_dkim_results(context, authres).await
}
#[derive(Debug)]
pub(crate) struct DkimResults {
/// Whether DKIM passed for this particular e-mail.
pub dkim_passed: bool,
}
impl fmt::Display for DkimResults {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
write!(fmt, "DKIM Results: Passed={}", self.dkim_passed)?;
Ok(())
}
}
type AuthservId = String;
#[derive(Debug, PartialEq)]
enum DkimResult {
/// The header explicitly said that DKIM passed
Passed,
/// The header explicitly said that DKIM failed
Failed,
/// The header didn't say anything about DKIM; this might mean that it wasn't
/// checked, but it might also mean that it failed. This is because some providers
/// (e.g. ik.me, mail.ru, posteo.de) don't add `dkim=none` to their
/// Authentication-Results if there was no DKIM.
Nothing,
}
type ParsedAuthresHeaders = Vec<(AuthservId, DkimResult)>;
fn parse_authres_headers(
headers: &mailparse::headers::Headers<'_>,
from_domain: &str,
) -> ParsedAuthresHeaders {
let mut res = Vec::new();
for header_value in headers.get_all_values(HeaderDef::AuthenticationResults.into()) {
let header_value = remove_comments(&header_value);
if let Some(mut authserv_id) = header_value.split(';').next() {
if authserv_id.contains(char::is_whitespace) || authserv_id.is_empty() {
// Outlook violates the RFC by not adding an authserv-id at all, which we notice
// because there is whitespace in the first identifier before the ';'.
// Authentication-Results-parsing still works securely because they remove incoming
// Authentication-Results headers.
// We just use an arbitrary authserv-id, it will work for Outlook, and in general,
// with providers not implementing the RFC correctly, someone can trick us
// into thinking that an incoming email is DKIM-correct, anyway.
// The most important thing here is that we have some valid `authserv_id`.
authserv_id = "invalidAuthservId";
}
let dkim_passed = parse_one_authres_header(&header_value, from_domain);
res.push((authserv_id.to_string(), dkim_passed));
}
}
res
}
/// The headers can contain comments that look like this:
/// ```text
/// Authentication-Results: (this is a comment) gmx.net; (another; comment) dkim=pass;
/// ```
fn remove_comments(header: &str) -> Cow<'_, str> {
// In Pomsky, this is:
// "(" Codepoint* lazy ")"
// See https://playground.pomsky-lang.org/?text=%22(%22%20Codepoint*%20lazy%20%22)%22
static RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\([\s\S]*?\)").unwrap());
RE.replace_all(header, " ")
}
/// Parses a single Authentication-Results header, like:
///
/// ```text
/// Authentication-Results: gmx.net; dkim=pass header.i=@slack.com
/// ```
fn parse_one_authres_header(header_value: &str, from_domain: &str) -> DkimResult {
if let Some((before_dkim_part, dkim_to_end)) = header_value.split_once("dkim=") {
// Check that the character right before `dkim=` is a space or a tab
// so that we wouldn't e.g. mistake `notdkim=pass` for `dkim=pass`
if before_dkim_part.ends_with(' ') || before_dkim_part.ends_with('\t') {
let dkim_part = dkim_to_end.split(';').next().unwrap_or_default();
let dkim_parts: Vec<_> = dkim_part.split_whitespace().collect();
if let Some(&"pass") = dkim_parts.first() {
// DKIM headers contain a header.d or header.i field
// that says which domain signed. We have to check ourselves
// that this is the same domain as in the From header.
let header_d: &str = &format!("header.d={}", &from_domain);
let header_i: &str = &format!("header.i=@{}", &from_domain);
if dkim_parts.contains(&header_d) || dkim_parts.contains(&header_i) {
// We have found a `dkim=pass` header!
return DkimResult::Passed;
}
} else {
// dkim=fail, dkim=none, ...
return DkimResult::Failed;
}
}
}
DkimResult::Nothing
}
/// ## About authserv-ids
///
/// After having checked DKIM, our email server adds an Authentication-Results header.
///
/// Now, an attacker could just add an Authentication-Results header that says dkim=pass
/// in order to make us think that DKIM was correct in their From-forged email.
///
/// In order to prevent this, each email server adds its authserv-id to the
/// Authentication-Results header, e.g. Testrun's authserv-id is `testrun.org`, Gmail's
/// is `mx.google.com`. When Testrun gets a mail delivered from outside, it will then
/// remove any Authentication-Results headers whose authserv-id is also `testrun.org`.
///
/// We need to somehow find out the authserv-id(s) of our email server, so that
/// we can use the Authentication-Results with the right authserv-id.
///
/// ## What this function does
///
/// When receiving an email, this function is called and updates the candidates for
/// our server's authserv-id, i.e. what we think our server's authserv-id is.
///
/// Usually, every incoming email has Authentication-Results with our server's
/// authserv-id, so, the intersection of the existing authserv-ids and the incoming
/// authserv-ids for our server's authserv-id is a good guess for our server's
/// authserv-id. When this intersection is empty, we assume that the authserv-id has
/// changed and start over with the new authserv-ids.
///
/// See [`handle_authres`].
async fn update_authservid_candidates(
context: &Context,
authres: &ParsedAuthresHeaders,
) -> Result<()> {
let mut new_ids: BTreeSet<&str> = authres
.iter()
.map(|(authserv_id, _dkim_passed)| authserv_id.as_str())
.collect();
if new_ids.is_empty() {
// The incoming message doesn't contain any authentication results, maybe it's a
// self-sent or a mailer-daemon message
return Ok(());
}
let old_config = context.get_config(Config::AuthservIdCandidates).await?;
let old_ids = parse_authservid_candidates_config(&old_config);
let intersection: BTreeSet<&str> = old_ids.intersection(&new_ids).copied().collect();
if !intersection.is_empty() {
new_ids = intersection;
}
// If there were no AuthservIdCandidates previously, just start with
// the ones from the incoming email
if old_ids != new_ids {
let new_config = new_ids.into_iter().collect::<Vec<_>>().join(" ");
context
.set_config_internal(Config::AuthservIdCandidates, Some(&new_config))
.await?;
}
Ok(())
}
/// Use the parsed authres and the authservid candidates to compute whether DKIM passed
/// and whether a keychange should be allowed.
///
/// We track in the `sending_domains` table whether we get positive Authentication-Results
/// for mails from a contact (meaning that their provider properly authenticates against
/// our provider).
///
/// Once a contact is known to come with positive Authentication-Resutls (dkim: pass),
/// we don't accept Autocrypt key changes if they come with negative Authentication-Results.
async fn compute_dkim_results(
context: &Context,
mut authres: ParsedAuthresHeaders,
) -> Result<DkimResults> {
let mut dkim_passed = false;
let ids_config = context.get_config(Config::AuthservIdCandidates).await?;
let ids = parse_authservid_candidates_config(&ids_config);
// Remove all foreign authentication results
authres.retain(|(authserv_id, _dkim_passed)| ids.contains(authserv_id.as_str()));
if authres.is_empty() {
// If the authentication results are empty, then our provider doesn't add them
// and an attacker could just add their own Authentication-Results, making us
// think that DKIM passed. So, in this case, we can as well assume that DKIM passed.
dkim_passed = true;
} else {
for (_authserv_id, current_dkim_passed) in authres {
match current_dkim_passed {
DkimResult::Passed => {
dkim_passed = true;
break;
}
DkimResult::Failed => {
dkim_passed = false;
break;
}
DkimResult::Nothing => {
// Continue looking for an Authentication-Results header
}
}
}
}
Ok(DkimResults { dkim_passed })
}
fn parse_authservid_candidates_config(config: &Option<String>) -> BTreeSet<&str> {
config
.as_deref()
.map(|c| c.split_whitespace().collect())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use tokio::fs;
use tokio::io::AsyncReadExt;
use super::*;
use crate::mimeparser;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools;
#[test]
fn test_remove_comments() {
let header = "Authentication-Results: mx3.messagingengine.com;
dkim=pass (1024-bit rsa key sha256) header.d=riseup.net;"
.to_string();
assert_eq!(
remove_comments(&header),
"Authentication-Results: mx3.messagingengine.com;
dkim=pass header.d=riseup.net;"
);
let header = ") aaa (".to_string();
assert_eq!(remove_comments(&header), ") aaa (");
let header = "((something weird) no comment".to_string();
assert_eq!(remove_comments(&header), " no comment");
let header = "🎉(🎉(🎉))🎉(".to_string();
assert_eq!(remove_comments(&header), "🎉 )🎉(");
// Comments are allowed to include whitespace
let header = "(com\n\t\r\nment) no comment (comment)".to_string();
assert_eq!(remove_comments(&header), " no comment ");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_authentication_results() -> Result<()> {
let t = TestContext::new().await;
t.configure_addr("alice@gmx.net").await;
let bytes = b"Authentication-Results: gmx.net; dkim=pass header.i=@slack.com
Authentication-Results: gmx.net; dkim=pass header.i=@amazonses.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(
actual,
vec![
("gmx.net".to_string(), DkimResult::Passed),
("gmx.net".to_string(), DkimResult::Nothing)
]
);
let bytes = b"Authentication-Results: gmx.net; notdkim=pass header.i=@slack.com
Authentication-Results: gmx.net; notdkim=pass header.i=@amazonses.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(
actual,
vec![
("gmx.net".to_string(), DkimResult::Nothing),
("gmx.net".to_string(), DkimResult::Nothing)
]
);
let bytes = b"Authentication-Results: gmx.net; dkim=pass header.i=@amazonses.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(actual, vec![("gmx.net".to_string(), DkimResult::Nothing)],);
// Weird Authentication-Results from Outlook without an authserv-id
let bytes = b"Authentication-Results: spf=pass (sender IP is 40.92.73.85)
smtp.mailfrom=hotmail.com; dkim=pass (signature was verified)
header.d=hotmail.com;dmarc=pass action=none
header.from=hotmail.com;compauth=pass reason=100";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "hotmail.com");
// At this point, the most important thing to test is that there are no
// authserv-ids with whitespace in them.
assert_eq!(
actual,
vec![("invalidAuthservId".to_string(), DkimResult::Passed)]
);
let bytes = b"Authentication-Results: gmx.net; dkim=none header.i=@slack.com
Authentication-Results: gmx.net; dkim=pass header.i=@slack.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(
actual,
vec![
("gmx.net".to_string(), DkimResult::Failed),
("gmx.net".to_string(), DkimResult::Passed)
]
);
// ';' in comments
let bytes = b"Authentication-Results: mx1.riseup.net;
dkim=pass (1024-bit key; unprotected) header.d=yandex.ru header.i=@yandex.ru header.a=rsa-sha256 header.s=mail header.b=avNJu6sw;
dkim-atps=neutral";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "yandex.ru");
assert_eq!(
actual,
vec![("mx1.riseup.net".to_string(), DkimResult::Passed)]
);
let bytes = br#"Authentication-Results: box.hispanilandia.net;
dkim=fail reason="signature verification failed" (2048-bit key; secure) header.d=disroot.org header.i=@disroot.org header.b="kqh3WUKq";
dkim-atps=neutral
Authentication-Results: box.hispanilandia.net; dmarc=pass (p=quarantine dis=none) header.from=disroot.org
Authentication-Results: box.hispanilandia.net; spf=pass smtp.mailfrom=adbenitez@disroot.org"#;
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "disroot.org");
assert_eq!(
actual,
vec![
("box.hispanilandia.net".to_string(), DkimResult::Failed),
("box.hispanilandia.net".to_string(), DkimResult::Nothing),
("box.hispanilandia.net".to_string(), DkimResult::Nothing),
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_authservid_candidates() -> Result<()> {
let t = TestContext::new_alice().await;
update_authservid_candidates_test(&t, &["mx3.messagingengine.com"]).await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx3.messagingengine.com");
// "mx4.messagingengine.com" seems to be the new authserv-id, DC should accept it
update_authservid_candidates_test(&t, &["mx4.messagingengine.com"]).await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx4.messagingengine.com");
// A message without any Authentication-Results headers shouldn't remove all
// candidates since it could be a mailer-daemon message or so
update_authservid_candidates_test(&t, &[]).await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx4.messagingengine.com");
update_authservid_candidates_test(&t, &["mx4.messagingengine.com", "someotherdomain.com"])
.await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx4.messagingengine.com");
Ok(())
}
/// Calls update_authservid_candidates(), meant for using in a test.
///
/// update_authservid_candidates() only looks at the keys of its
/// `authentication_results` parameter. So, this function takes `incoming_ids`
/// and adds some AuthenticationResults to get the HashMap we need.
async fn update_authservid_candidates_test(context: &Context, incoming_ids: &[&str]) {
let v = incoming_ids
.iter()
.map(|id| (id.to_string(), DkimResult::Passed))
.collect();
update_authservid_candidates(context, &v).await.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_realworld_authentication_results() -> Result<()> {
let mut test_failed = false;
let dir = tools::read_dir("test-data/message/dkimchecks-2022-09-28/".as_ref())
.await
.unwrap();
let mut bytes = Vec::new();
for entry in dir {
if !entry.file_type().await.unwrap().is_dir() {
continue;
}
let self_addr = entry.file_name().into_string().unwrap();
let self_domain = EmailAddress::new(&self_addr).unwrap().domain;
let authres_parsing_works = [
"ik.me",
"web.de",
"posteo.de",
"gmail.com",
"hotmail.com",
"mail.ru",
"aol.com",
"yahoo.com",
"icloud.com",
"fastmail.com",
"mail.de",
"outlook.com",
"gmx.de",
"testrun.org",
]
.contains(&self_domain.as_str());
let t = TestContext::new().await;
t.configure_addr(&self_addr).await;
if !authres_parsing_works {
println!("========= Receiving as {} =========", &self_addr);
}
// Simulate receiving all emails once, so that we have the correct authserv-ids
let mut dir = tools::read_dir(&entry.path()).await.unwrap();
// The ordering in which the emails are received can matter;
// the test _should_ pass for every ordering.
dir.sort_by_key(|d| d.file_name());
//rand::seq::SliceRandom::shuffle(&mut dir[..], &mut rand::rng());
for entry in &dir {
let mut file = fs::File::open(entry.path()).await?;
bytes.clear();
file.read_to_end(&mut bytes).await.unwrap();
let mail = mailparse::parse_mail(&bytes)?;
let from = &mimeparser::get_from(&mail.headers).unwrap().addr;
let res = handle_authres(&t, &mail, from).await?;
let from_domain = EmailAddress::new(from).unwrap().domain;
// delta.blinzeln.de and gmx.de have invalid DKIM, so the DKIM check should fail
let expected_result = (from_domain != "delta.blinzeln.de") && (from_domain != "gmx.de")
// These are (fictional) forged emails where the attacker added a fake
// Authentication-Results before sending the email
&& from != "forged-authres-added@example.com"
// Other forged emails
&& !from.starts_with("forged");
if res.dkim_passed != expected_result {
if authres_parsing_works {
println!(
"!!!!!! FAILURE Receiving {:?} wrong result: !!!!!!",
entry.path(),
);
test_failed = true;
}
println!("From {}: {}", from_domain, res.dkim_passed);
}
}
}
assert!(!test_failed);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handle_authres() {
let t = TestContext::new().await;
// Even if the format is wrong and parsing fails, handle_authres() shouldn't
// return an Err because this would prevent the message from being added
// to the database and downloaded again and again
let bytes = b"From: invalid@from.com
Authentication-Results: dkim=";
let mail = mailparse::parse_mail(bytes).unwrap();
handle_authres(&t, &mail, "invalid@rom.com").await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_authres_in_mailinglist_ignored() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// Bob knows his server's authserv-id
bob.set_config(Config::AuthservIdCandidates, Some("example.net"))
.await?;
let alice_bob_chat = alice.create_chat(&bob).await;
let mut sent = alice.send_text(alice_bob_chat.id, "hellooo").await;
sent.payload
.insert_str(0, "List-Post: <mailto:deltachat-community.example.net>\n");
sent.payload
.insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
let rcvd = bob.recv_msg(&sent).await;
assert!(rcvd.error.is_none());
// Do the same without the mailing list header, this time the failed
// authres isn't ignored
let mut sent = alice
.send_text(alice_bob_chat.id, "hellooo without mailing list")
.await;
sent.payload
.insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
let rcvd = bob.recv_msg(&sent).await;
// The message info should contain a warning:
assert!(
rcvd.id
.get_info(&bob)
.await
.unwrap()
.contains("DKIM Results: Passed=false")
);
Ok(())
}
}

View File

@@ -284,6 +284,10 @@ impl<'a> BlobObject<'a> {
///
/// Recoding is only done for [`Viewtype::Image`]. For [`Viewtype::File`], if it's a correct
/// image, `*viewtype` is set to [`Viewtype::Image`].
///
/// On some platforms images are passed to Core as [`Viewtype::Sticker`]. We recheck if the
/// image is a true sticker assuming that it must have at least one fully transparent corner,
/// otherwise `*viewtype` is set to [`Viewtype::Image`].
pub async fn check_or_recode_image(
&mut self,
context: &Context,

View File

@@ -445,6 +445,7 @@ async fn test_recode_image_balanced_png() {
.await
.unwrap();
// This will be sent as Image, see [`BlobObject::check_or_recode_image()`] for explanation.
SendImageCheckMediaquality {
viewtype: Viewtype::Sticker,
media_quality_config: "0",
@@ -452,7 +453,6 @@ async fn test_recode_image_balanced_png() {
extension: "png",
original_width: 1920,
original_height: 1080,
res_viewtype: Some(Viewtype::Sticker),
compressed_width: 1920,
compressed_height: 1080,
..Default::default()
@@ -734,6 +734,8 @@ async fn test_send_gif_as_sticker() -> Result<()> {
let chat = alice.get_self_chat().await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let msg = Message::load_from_db(alice, sent.sender_msg_id).await?;
// Message::force_sticker() wasn't used, still Viewtype::Sticker is preserved because of the
// extension.
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
Ok(())
}

View File

@@ -6,6 +6,7 @@ use std::fmt;
use std::io::Cursor;
use std::marker::Sync;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
@@ -22,9 +23,8 @@ use crate::chatlist_events;
use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{
self, Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK,
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX,
TIMESTAMP_SENT_TOLERANCE,
Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK, DC_CHAT_ID_LAST_SPECIAL,
DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS, EDITED_PREFIX, TIMESTAMP_SENT_TOLERANCE,
};
use crate::contact::{self, Contact, ContactId, Origin};
use crate::context::Context;
@@ -34,7 +34,7 @@ use crate::download::{
};
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key::{Fingerprint, self_fingerprint};
use crate::key::self_fingerprint;
use crate::location;
use crate::log::{LogExt, warn};
use crate::logged_debug_assert;
@@ -1210,8 +1210,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
);
let fingerprint = contact
.fingerprint()
.context("Contact does not have a fingerprint in encrypted chat")?
.human_readable();
.context("Contact does not have a fingerprint in encrypted chat")?;
if let Some(public_key) = contact.public_key(context).await? {
if let Some(relay_addrs) = addresses_from_public_key(&public_key) {
let relays = relay_addrs.join(",");
@@ -2467,7 +2466,10 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
.with_context(|| format!("attachment missing for message of type #{}", msg.viewtype))?;
let mut maybe_image = false;
if msg.viewtype == Viewtype::File || msg.viewtype == Viewtype::Image {
if msg.viewtype == Viewtype::File
|| msg.viewtype == Viewtype::Image
|| msg.viewtype == Viewtype::Sticker && !msg.param.exists(Param::ForceSticker)
{
// Correct the type, take care not to correct already very special
// formats as GIF or VOICE.
//
@@ -2475,7 +2477,12 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
// - from FILE to AUDIO/VIDEO/IMAGE
// - from FILE/IMAGE to GIF */
if let Some((better_type, _)) = message::guess_msgtype_from_suffix(msg) {
if better_type == Viewtype::Image {
if msg.viewtype == Viewtype::Sticker {
if better_type != Viewtype::Image {
// UIs don't want conversions of `Sticker` to anything other than `Image`.
msg.param.set_int(Param::ForceSticker, 1);
}
} else if better_type == Viewtype::Image {
maybe_image = true;
} else if better_type != Viewtype::Webxdc
|| context
@@ -2495,7 +2502,10 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
if msg.viewtype == Viewtype::Vcard {
msg.try_set_vcard(context, &blob.to_abs_path()).await?;
}
if msg.viewtype == Viewtype::File && maybe_image || msg.viewtype == Viewtype::Image {
if msg.viewtype == Viewtype::File && maybe_image
|| msg.viewtype == Viewtype::Image
|| msg.viewtype == Viewtype::Sticker && !msg.param.exists(Param::ForceSticker)
{
let new_name = blob
.check_or_recode_image(context, msg.get_filename(), &mut msg.viewtype)
.await?;
@@ -2613,7 +2623,7 @@ pub async fn send_msg(context: &Context, chat_id: ChatId, msg: &mut Message) ->
"chat_id cannot be a special chat: {chat_id}"
);
if msg.state != MessageState::Undefined {
if msg.state != MessageState::Undefined && msg.state != MessageState::OutPreparing {
msg.param.remove(Param::GuaranteeE2ee);
msg.param.remove(Param::ForcePlaintext);
// create_send_msg_jobs() will update `param` in the db.
@@ -2721,7 +2731,10 @@ async fn prepare_send_msg(
None
};
if msg.state == MessageState::Undefined
if matches!(
msg.state,
MessageState::Undefined | MessageState::OutPreparing
)
// Legacy SecureJoin "v*-request" messages are unencrypted.
&& msg.param.get_cmd() != SystemMessage::SecurejoinMessage
&& chat.is_encrypted(context).await?
@@ -2933,19 +2946,17 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
"
UPDATE msgs SET
timestamp=(
SELECT MAX(timestamp) FROM msgs INDEXED BY msgs_index7 WHERE
-- From `InFresh` to `OutDelivered` inclusive, except `OutDraft`.
state IN(10,13,16,18,20,24,26) AND
SELECT MAX(timestamp) FROM msgs WHERE
-- From `InFresh` to `OutMdnRcvd` inclusive except `OutDraft`.
state IN(10,13,16,18,20,24,26,28) AND
hidden IN(0,1) AND
chat_id=? AND
id<=?
chat_id=?
),
pre_rfc724_mid=?, subject=?, param=?
WHERE id=?
",
(
msg.chat_id,
msg.id,
&msg.pre_rfc724_mid,
&msg.subject,
msg.param.to_string(),
@@ -3094,6 +3105,9 @@ async fn donation_request_maybe(context: &Context) -> Result<()> {
/// Chat message list request options.
#[derive(Debug)]
pub struct MessageListOptions {
/// Return only info messages.
pub info_only: bool,
/// Add day markers before each date regarding the local timezone.
pub add_daymarker: bool,
}
@@ -3104,27 +3118,56 @@ pub async fn get_chat_msgs(context: &Context, chat_id: ChatId) -> Result<Vec<Cha
context,
chat_id,
MessageListOptions {
info_only: false,
add_daymarker: false,
},
)
.await
}
/// Returns messages belonging to the chat according to the given options,
/// sorted by oldest message first.
/// Returns messages belonging to the chat according to the given options.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_chat_msgs_ex(
context: &Context,
chat_id: ChatId,
options: MessageListOptions,
) -> Result<Vec<ChatItem>> {
let MessageListOptions { add_daymarker } = options;
let process_row = |row: &rusqlite::Row| {
Ok((
row.get::<_, i64>("timestamp")?,
row.get::<_, MsgId>("id")?,
false,
))
let MessageListOptions {
info_only,
add_daymarker,
} = options;
let process_row = if info_only {
|row: &rusqlite::Row| {
// is_info logic taken from Message.is_info()
let params = row.get::<_, String>("param")?;
let (from_id, to_id) = (
row.get::<_, ContactId>("from_id")?,
row.get::<_, ContactId>("to_id")?,
);
let is_info_msg: bool = from_id == ContactId::INFO
|| to_id == ContactId::INFO
|| match Params::from_str(&params) {
Ok(p) => {
let cmd = p.get_cmd();
cmd != SystemMessage::Unknown && cmd != SystemMessage::AutocryptSetupMessage
}
_ => false,
};
Ok((
row.get::<_, i64>("timestamp")?,
row.get::<_, MsgId>("id")?,
!is_info_msg,
))
}
} else {
|row: &rusqlite::Row| {
Ok((
row.get::<_, i64>("timestamp")?,
row.get::<_, MsgId>("id")?,
false,
))
}
};
let process_rows = |rows: rusqlite::AndThenRows<_>| {
// It is faster to sort here rather than
@@ -3159,18 +3202,39 @@ pub async fn get_chat_msgs_ex(
Ok(ret)
};
let items = context
.sql
.query_map(
"SELECT m.id AS id, m.timestamp AS timestamp
let items = if info_only {
context
.sql
.query_map(
// GLOB is used here instead of LIKE because it is case-sensitive
"SELECT m.id AS id, m.timestamp AS timestamp, m.param AS param, m.from_id AS from_id, m.to_id AS to_id
FROM msgs m
WHERE m.chat_id=?
AND m.hidden=0
AND (
m.param GLOB '*\nS=*' OR param GLOB 'S=*'
OR m.from_id == ?
OR m.to_id == ?
);",
(chat_id, ContactId::INFO, ContactId::INFO),
process_row,
process_rows,
)
.await?
} else {
context
.sql
.query_map(
"SELECT m.id AS id, m.timestamp AS timestamp
FROM msgs m
WHERE m.chat_id=?
AND m.hidden=0;",
(chat_id,),
process_row,
process_rows,
)
.await?;
(chat_id,),
process_row,
process_rows,
)
.await?
};
Ok(items)
}
@@ -3945,47 +4009,9 @@ pub(crate) async fn add_contact_to_chat_ex(
if sync.into() {
chat.sync_contacts(context).await.log_err(context).ok();
}
if chat.typ == Chattype::OutBroadcast {
resend_last_msgs(context, chat.id, &contact)
.await
.log_err(context)
.ok();
}
Ok(true)
}
async fn resend_last_msgs(context: &Context, chat_id: ChatId, to_contact: &Contact) -> Result<()> {
let msgs: Vec<MsgId> = context
.sql
.query_map_vec(
"
SELECT id
FROM msgs
WHERE chat_id=?
AND hidden=0
AND NOT ( -- Exclude info and system messages
param GLOB '*\nS=*' OR param GLOB 'S=*'
OR from_id=?
OR to_id=?
)
AND type!=?
ORDER BY timestamp DESC, id DESC LIMIT ?",
(
chat_id,
ContactId::INFO,
ContactId::INFO,
Viewtype::Webxdc,
constants::N_MSGS_TO_NEW_BROADCAST_MEMBER,
),
|row: &rusqlite::Row| Ok(row.get::<_, MsgId>(0)?),
)
.await?
.into_iter()
.rev()
.collect();
resend_msgs_ex(context, &msgs, to_contact.fingerprint()).await
}
/// Returns true if an avatar should be attached in the given chat.
///
/// This function does not check if the avatar is set.
@@ -4536,7 +4562,6 @@ pub async fn forward_msgs_2ctx(
msg.state = MessageState::OutPending;
msg.rfc724_mid = create_outgoing_rfc724_mid();
msg.pre_rfc724_mid.clear();
msg.timestamp_sort = curr_timestamp;
chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?;
@@ -4650,26 +4675,10 @@ pub(crate) async fn save_copy_in_self_talk(
Ok(msg.rfc724_mid)
}
/// Resends given messages to members of the corresponding chats.
/// Resends given messages with the same Message-ID.
///
/// This is primarily intended to make existing webxdcs available to new chat members.
pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
resend_msgs_ex(context, msg_ids, None).await
}
/// Resends given messages to a contact with fingerprint `to_fingerprint` or, if it's `None`, to
/// members of the corresponding chats.
///
/// NB: Actually `to_fingerprint` is only passed for `OutBroadcast` chats when a new member is
/// added. Regarding webxdcs: It is not trivial to resend only the own status updates,
/// and it is not trivial to resend them only to the newly-joined member,
/// so that for now, [`resend_last_msgs`] does not automatically resend webxdcs at all.
pub(crate) async fn resend_msgs_ex(
context: &Context,
msg_ids: &[MsgId],
to_fingerprint: Option<Fingerprint>,
) -> Result<()> {
let to_fingerprint = to_fingerprint.map(|f| f.hex());
let mut msgs: Vec<Message> = Vec::new();
for msg_id in msg_ids {
let msg = Message::load_from_db(context, *msg_id).await?;
@@ -4688,17 +4697,10 @@ pub(crate) async fn resend_msgs_ex(
| MessageState::OutFailed
| MessageState::OutDelivered
| MessageState::OutMdnRcvd => {
// Broadcast owners shouldn't see spinners on messages being auto-re-sent to new
// subscribers (otherwise big channel owners will see spinners most of the time).
if to_fingerprint.is_none() {
message::update_msg_state(context, msg.id, MessageState::OutPending).await?;
}
message::update_msg_state(context, msg.id, MessageState::OutPending).await?
}
msg_state => bail!("Unexpected message state {msg_state}"),
}
if let Some(to_fingerprint) = &to_fingerprint {
msg.param.set(Param::Arg4, to_fingerprint.clone());
}
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
@@ -4710,8 +4712,7 @@ pub(crate) async fn resend_msgs_ex(
chat_id: msg.chat_id,
msg_id: msg.id,
});
// The event only matters if the message is last in the chat.
// But it's probably too expensive check, and UIs anyways need to debounce.
// note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
if msg.viewtype == Viewtype::Webxdc {
@@ -4904,6 +4905,8 @@ pub async fn was_device_msg_ever_added(context: &Context, label: &str) -> Result
// no wrong information are shown in the device chat
// - deletion in `devmsglabels` makes sure,
// deleted messages are reset and useful messages can be added again
// - we reset the config-option `QuotaExceeding`
// that is used as a helper to drive the corresponding device message.
pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Result<()> {
context
.sql
@@ -4919,6 +4922,9 @@ pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Resul
(),
)
.await?;
context
.set_config_internal(Config::QuotaExceeding, None)
.await?;
Ok(())
}

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use super::*;
use crate::Event;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS, N_MSGS_TO_NEW_BROADCAST_MEMBER};
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
use crate::ephemeral::Timer;
use crate::headerdef::HeaderDef;
use crate::imex::{ImexMode, has_backup, imex};
@@ -2032,6 +2032,12 @@ async fn test_classic_email_chat() -> Result<()> {
let msgs = get_chat_msgs(&alice, chat_id).await?;
assert_eq!(msgs.len(), 1);
// Alice disables receiving classic emails.
alice
.set_config(Config::ShowEmails, Some("0"))
.await
.unwrap();
// Already received classic email should still be in the chat.
assert_eq!(chat_id.get_fresh_msg_cnt(&alice).await?, 1);
@@ -2069,7 +2075,13 @@ async fn test_chat_get_color_encrypted() -> Result<()> {
Ok(())
}
async fn test_sticker(filename: &str, bytes: &[u8], w: i32, h: i32) -> Result<()> {
async fn test_sticker(
filename: &str,
bytes: &[u8],
res_viewtype: Viewtype,
w: i32,
h: i32,
) -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
@@ -2085,7 +2097,7 @@ async fn test_sticker(filename: &str, bytes: &[u8], w: i32, h: i32) -> Result<()
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, bob_chat.id);
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
assert_eq!(msg.get_viewtype(), res_viewtype);
assert_eq!(msg.get_filename().unwrap(), filename);
assert_eq!(msg.get_width(), w);
assert_eq!(msg.get_height(), h);
@@ -2099,6 +2111,7 @@ async fn test_sticker_png() -> Result<()> {
test_sticker(
"sticker.png",
include_bytes!("../../test-data/image/logo.png"),
Viewtype::Sticker,
135,
135,
)
@@ -2110,6 +2123,7 @@ async fn test_sticker_jpeg() -> Result<()> {
test_sticker(
"sticker.jpg",
include_bytes!("../../test-data/image/avatar1000x1000.jpg"),
Viewtype::Image,
1000,
1000,
)
@@ -2117,33 +2131,10 @@ async fn test_sticker_jpeg() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_gif() -> Result<()> {
test_sticker(
"sticker.gif",
include_bytes!("../../test-data/image/logo.gif"),
135,
135,
)
.await
}
/// Tests that stickers are sent as stickers.
///
/// Previously there was heuristic that stickers
/// were sometimes turned into non-stickers,
/// e.g. when it looked like UI sent
/// a screenshot dragged from the gallery into chat
/// as a sticker.
///
/// We have no such heuristic anymore,
/// if such heuristic is needed on some platform,
/// UI code should implement it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_no_heuristics() {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
async fn test_sticker_jpeg_force() {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let file = alice.get_blobdir().join("sticker.jpg");
tokio::fs::write(
@@ -2153,38 +2144,53 @@ async fn test_sticker_no_heuristics() {
.await
.unwrap();
// Send a sticker.
// Images without force_sticker should be turned into [Viewtype::Image]
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, Some("sticker.jpg"), None)
msg.set_file_and_deduplicate(&alice, &file, Some("sticker.jpg"), None)
.unwrap();
let file = msg.get_file(alice).unwrap();
let file = msg.get_file(&alice).unwrap();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Image);
// Images with `force_sticker = true` should keep [Viewtype::Sticker]
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(&alice, &file, Some("sticker.jpg"), None)
.unwrap();
msg.force_sticker();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
// Send a sticker reusing the file.
// Images with `force_sticker = true` should keep [Viewtype::Sticker]
// even on drafted messages
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, Some("sticker.jpg"), None)
.unwrap();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
// Set sticker as a draft, then send it.
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, Some("sticker.jpg"), None)
msg.set_file_and_deduplicate(&alice, &file, Some("sticker.jpg"), None)
.unwrap();
msg.force_sticker();
alice_chat
.id
.set_draft(alice, Some(&mut msg))
.set_draft(&alice, Some(&mut msg))
.await
.unwrap();
let mut msg = alice_chat.id.get_draft(alice).await.unwrap().unwrap();
let mut msg = alice_chat.id.get_draft(&alice).await.unwrap().unwrap();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_gif() -> Result<()> {
test_sticker(
"sticker.gif",
include_bytes!("../../test-data/image/logo.gif"),
Viewtype::Sticker,
135,
135,
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_forward() -> Result<()> {
// create chats
@@ -2686,49 +2692,6 @@ async fn test_resend_own_message() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_doesnt_resort_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_grp = create_group(alice, "").await?;
let sent1 = alice.send_text(alice_grp, "hi").await;
let sent1_ts = Message::load_from_db(alice, sent1.sender_msg_id)
.await?
.timestamp_sort;
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, alice_grp, alice.add_or_lookup_contact_id(bob).await).await?;
let sent2 = alice
.send_text(
alice_grp,
"Let's test resending, there are very few tests on it",
)
.await;
let resent_msg_id = sent1.sender_msg_id;
resend_msgs(alice, &[resent_msg_id]).await?;
assert_eq!(
resent_msg_id.get_state(alice).await?,
MessageState::OutPending
);
alice.pop_sent_msg().await;
assert_eq!(
resent_msg_id.get_state(alice).await?,
MessageState::OutDelivered
);
assert_eq!(
Message::load_from_db(alice, sent1.sender_msg_id)
.await?
.timestamp_sort,
sent1_ts
);
assert_eq!(
alice.get_last_msg_id_in(alice_grp).await,
sent2.sender_msg_id
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_foreign_message_fails() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -2842,15 +2805,6 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
"alice@example.org charlie@example.net"
);
// Check additionally that subscribers don't send "Chat-Group-Name*" headers.
let parsed = alice.parse_msg(&request_with_auth).await;
assert!(parsed.get_header(HeaderDef::ChatGroupName).is_none());
assert!(
parsed
.get_header(HeaderDef::ChatGroupNameTimestamp)
.is_none()
);
alice.recv_msg_trash(&request_with_auth).await;
}
@@ -2993,56 +2947,6 @@ async fn test_broadcast_change_name() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_resend_to_new_member() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_bc_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_bc_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let mut alice_msg_ids = Vec::new();
for i in 0..(N_MSGS_TO_NEW_BROADCAST_MEMBER + 1) {
alice_msg_ids.push(
alice
.send_text(alice_bc_id, &i.to_string())
.await
.sender_msg_id,
);
}
let fiona_bc_id = tcm.exec_securejoin_qr(fiona, alice, &qr).await;
for msg_id in alice_msg_ids {
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutDelivered);
}
for i in 0..N_MSGS_TO_NEW_BROADCAST_MEMBER {
let rev_order = false;
let resent_msg = alice
.pop_sent_msg_ex(rev_order, Duration::ZERO)
.await
.unwrap();
let fiona_msg = fiona.recv_msg(&resent_msg).await;
assert_eq!(fiona_msg.chat_id, fiona_bc_id);
assert_eq!(fiona_msg.text, (i + 1).to_string());
assert!(resent_msg.recipients.contains("fiona@example.net"));
assert!(!resent_msg.recipients.contains("bob@"));
// The message is undecryptable for Bob, he mustn't be able to know yet that somebody joined
// the broadcast even if he is a postman in this land. E.g. Fiona may leave after fetching
// the news, Bob won't know about that.
assert!(
MimeMessage::from_bytes(bob, resent_msg.payload().as_bytes())
.await?
.decryption_error
.is_some()
);
bob.recv_msg_trash(&resent_msg).await;
}
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
Ok(())
}
/// - Alice has multiple devices
/// - Alice creates a broadcast and sends a message into it
/// - Alice's second device sees the broadcast
@@ -5816,7 +5720,7 @@ async fn test_send_delete_request() -> Result<()> {
let sent2 = alice.pop_sent_msg().await;
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, E2EE_INFO_MSGS + 1);
// Bob receives both messages and has nothing at the end
// Bob receives both messages and has nothing the end
let bob_msg = bob.recv_msg(&sent1).await;
assert_eq!(bob_msg.text, "wtf");
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 2);
@@ -5824,11 +5728,6 @@ async fn test_send_delete_request() -> Result<()> {
bob.recv_msg_opt(&sent2).await;
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 1);
// ... even if he receives messages in reverse order.
let bob2 = &tcm.bob().await;
bob2.recv_msg_opt(&sent2).await;
assert!(bob2.recv_msg_opt(&sent1).await.is_none());
// Alice has another device, and there is also nothing at the end
let alice2 = &tcm.alice().await;
alice2.recv_msg(&sent0).await;

View File

@@ -42,85 +42,50 @@ use crate::{constants, stats};
)]
#[strum(serialize_all = "snake_case")]
pub enum Config {
/// Deprecated(2026-04).
/// Use ConfiguredAddr, [`crate::login_param::EnteredLoginParam`],
/// or add_transport{from_qr}()/list_transports() instead.
///
/// Email address, used in the `From:` field.
Addr,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server hostname.
MailServer,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server username.
MailUser,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server password.
MailPw,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server port.
MailPort,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server security (e.g. TLS, STARTTLS).
MailSecurity,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// How to check TLS certificates.
///
/// "IMAP" in the name is for compatibility,
/// this actually applies to both IMAP and SMTP connections.
ImapCertificateChecks,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server hostname.
SendServer,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server username.
SendUser,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server password.
SendPw,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server port.
SendPort,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server security (e.g. TLS, STARTTLS).
SendSecurity,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
/// Deprecated option for backwards compatibility.
///
/// Certificate checks for SMTP are actually controlled by `imap_certificate_checks` config.
SmtpCertificateChecks,
/// Whether to use OAuth 2.
///
/// Historically contained other bitflags, which are now deprecated.
@@ -190,6 +155,10 @@ pub enum Config {
#[strum(props(default = "1"))]
MdnsEnabled,
/// Whether to show classic emails or only chat messages.
#[strum(props(default = "2"))] // also change ShowEmails.default() on changes
ShowEmails,
/// Quality of the media files to send.
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
MediaQuality,
@@ -216,47 +185,32 @@ pub enum Config {
/// The primary email address.
ConfiguredAddr,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// List of configured IMAP servers as a JSON array.
ConfiguredImapServers,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server hostname.
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailServer,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server port.
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailPort,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server security (e.g. TLS, STARTTLS).
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailSecurity,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server username.
///
/// This is set if user has configured username manually.
ConfiguredMailUser,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server password.
ConfiguredMailPw,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured TLS certificate checks.
/// This option is saved on successful configuration
/// and should not be modified manually.
@@ -265,53 +219,37 @@ pub enum Config {
/// but has "IMAP" in the name for backwards compatibility.
ConfiguredImapCertificateChecks,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// List of configured SMTP servers as a JSON array.
ConfiguredSmtpServers,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server hostname.
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendServer,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server port.
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendPort,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server security (e.g. TLS, STARTTLS).
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendSecurity,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server username.
///
/// This is set if user has configured username manually.
ConfiguredSendUser,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server password.
ConfiguredSendPw,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
/// Deprecated, stored for backwards compatibility.
///
/// ConfiguredImapCertificateChecks is actually used.
ConfiguredSmtpCertificateChecks,
/// Whether OAuth 2 is used with configured provider.
ConfiguredServerFlags,
@@ -324,9 +262,6 @@ pub enum Config {
/// ID of the configured provider from the provider database.
ConfiguredProvider,
/// Deprecated(2026-04).
/// Use [`Context::is_configured()`] instead.
///
/// True if account is configured.
Configured,
@@ -367,6 +302,11 @@ pub enum Config {
#[strum(props(default = "0"))]
NotifyAboutWrongPw,
/// If a warning about exceeding quota was shown recently,
/// this is the percentage of quota at the time the warning was given.
/// Unset, when quota falls below minimal warning threshold again.
QuotaExceeding,
/// Timestamp of the last time housekeeping was run
LastHousekeeping,
@@ -407,6 +347,15 @@ pub enum Config {
#[strum(props(default = "1"))]
SyncMsgs,
/// Space-separated list of all the authserv-ids which we believe
/// may be the one of our email server.
///
/// See `crate::authres::update_authservid_candidates`.
AuthservIdCandidates,
/// Make all outgoing messages with Autocrypt header "multipart/signed".
SignUnencrypted,
/// Let the core save all events to the database.
/// This value is used internally to remember the MsgId of the logging xdc
#[strum(props(default = "0"))]
@@ -501,7 +450,11 @@ impl Config {
pub(crate) fn is_synced(&self) -> bool {
matches!(
self,
Self::Displayname | Self::MdnsEnabled | Self::Selfavatar | Self::Selfstatus,
Self::Displayname
| Self::MdnsEnabled
| Self::ShowEmails
| Self::Selfavatar
| Self::Selfstatus,
)
}
@@ -707,6 +660,7 @@ impl Context {
| Config::Bot
| Config::NotifyAboutWrongPw
| Config::SyncMsgs
| Config::SignUnencrypted
| Config::DisableIdle => {
ensure!(
matches!(value, None | Some("0") | Some("1")),
@@ -940,23 +894,16 @@ impl Context {
/// Determine whether the specified addr maps to the/a self addr.
/// Returns `false` if no addresses are configured.
pub(crate) async fn is_self_addr(&self, addr: &str) -> Result<bool> {
// Employ the config cache to optimize for `ConfiguredAddr` passed.
if !addr.is_empty()
&& addr_cmp(
addr,
&self
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default(),
)
{
return Ok(true);
}
Ok(self
.get_all_self_addrs()
.get_config(Config::ConfiguredAddr)
.await?
.iter()
.any(|a| addr_cmp(addr, a)))
.any(|a| addr_cmp(addr, a))
|| self
.get_secondary_self_addrs()
.await?
.iter()
.any(|a| addr_cmp(addr, a)))
}
/// Sets `primary_new` as the new primary self address and saves the old
@@ -1003,6 +950,14 @@ impl Context {
.await
}
/// Returns all secondary self addresses.
pub(crate) async fn get_secondary_self_addrs(&self) -> Result<Vec<String>> {
self.sql.query_map_vec("SELECT addr FROM transports WHERE addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')", (), |row| {
let addr: String = row.get(0)?;
Ok(addr)
}).await
}
/// Returns all published secondary self addresses.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_secondary_self_addrs(&self) -> Result<Vec<String>> {

View File

@@ -196,6 +196,13 @@ async fn test_sync() -> Result<()> {
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::MdnsEnabled).await?, false);
{
let val = alice0.get_config_bool(Config::ShowEmails).await?;
alice0.set_config_bool(Config::ShowEmails, !val).await?;
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::ShowEmails).await?, !val);
}
// `Config::SyncMsgs` mustn't be synced.
alice0.set_config_bool(Config::SyncMsgs, false).await?;
alice0.set_config_bool(Config::SyncMsgs, true).await?;

View File

@@ -76,7 +76,7 @@ impl Context {
/// Deprecated since 2025-02; use `add_transport_from_qr()`
/// or `add_or_update_transport()` instead.
pub async fn configure(&self) -> Result<()> {
let mut param = EnteredLoginParam::load_legacy(self).await?;
let mut param = EnteredLoginParam::load(self).await?;
self.add_transport_inner(&mut param).await
}
@@ -150,7 +150,7 @@ impl Context {
progress!(self, 0, Some(error_msg.clone()));
bail!(error_msg);
} else {
param.save_legacy(self).await?;
param.save(self).await?;
progress!(self, 1000);
}

View File

@@ -36,6 +36,17 @@ pub enum Blocked {
Request = 2,
}
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
#[repr(u8)]
pub enum ShowEmails {
Off = 0,
AcceptedContacts = 1,
#[default] // also change Config.ShowEmails props(default) on changes
All = 2,
}
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
@@ -233,9 +244,6 @@ Here is what to do:
If you have any questions, please send an email to delta@merlinux.eu or ask at https://support.delta.chat/."#;
/// How many recent messages should be re-sent to a new broadcast member.
pub(crate) const N_MSGS_TO_NEW_BROADCAST_MEMBER: usize = 10;
#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;
@@ -251,6 +259,18 @@ mod tests {
assert_eq!(Chattype::OutBroadcast, Chattype::from_i32(160).unwrap());
}
#[test]
fn test_showemails_values() {
// values may be written to disk and must not change
assert_eq!(ShowEmails::All, ShowEmails::default());
assert_eq!(ShowEmails::Off, ShowEmails::from_i32(0).unwrap());
assert_eq!(
ShowEmails::AcceptedContacts,
ShowEmails::from_i32(1).unwrap()
);
assert_eq!(ShowEmails::All, ShowEmails::from_i32(2).unwrap());
}
#[test]
fn test_blocked_values() {
// values may be written to disk and must not change

View File

@@ -1396,7 +1396,7 @@ WHERE addr=?
let Some(fingerprint_other) = contact.fingerprint() else {
return Ok(stock_str::encr_none(context));
};
let fingerprint_other = fingerprint_other.human_readable();
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::messages_are_e2ee(context)
@@ -1410,7 +1410,7 @@ WHERE addr=?
let fingerprint_self = load_self_public_key(context)
.await?
.dc_fingerprint()
.human_readable();
.to_string();
if addr < contact.addr {
cat_fingerprint(
&mut ret,

View File

@@ -568,6 +568,15 @@ impl Context {
}
}
/// Requests deletion of all messages from chatmail relays.
///
/// Non-chatmail relays are excluded
/// to avoid accidentally deleting emails
/// from shared inboxes.
pub async fn clear_all_relay_storage(&self) -> Result<()> {
self.scheduler.clear_all_relay_storage().await
}
/// Restarts the IO scheduler if it was running before
/// when it is not running this is an no-op
pub async fn restart_io_if_running(&self) {
@@ -843,7 +852,7 @@ impl Context {
/// Returns information about the context as key-value pairs.
pub async fn get_info(&self) -> Result<BTreeMap<&'static str, String>> {
let all_self_addrs = self.get_all_self_addrs().await?.join(", ");
let secondary_addrs = self.get_secondary_self_addrs().await?.join(", ");
let all_transports: Vec<String> = ConfiguredLoginParam::load_all(self)
.await?
.into_iter()
@@ -945,7 +954,11 @@ impl Context {
}
}
res.insert("all_self_addrs", all_self_addrs);
res.insert("secondary_addrs", secondary_addrs);
res.insert(
"show_emails",
self.get_config_int(Config::ShowEmails).await?.to_string(),
);
res.insert(
"who_can_call_me",
self.get_config_int(Config::WhoCanCallMe).await?.to_string(),
@@ -991,6 +1004,24 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"quota_exceeding",
self.get_config_int(Config::QuotaExceeding)
.await?
.to_string(),
);
res.insert(
"authserv_id_candidates",
self.get_config(Config::AuthservIdCandidates)
.await?
.unwrap_or_default(),
);
res.insert(
"sign_unencrypted",
self.get_config_int(Config::SignUnencrypted)
.await?
.to_string(),
);
res.insert(
"debug_logging",
self.get_config_int(Config::DebugLogging).await?.to_string(),

View File

@@ -284,6 +284,7 @@ async fn test_get_info_completeness() {
"send_security",
"server_flags",
"skip_start_messages",
"smtp_certificate_checks",
"proxy_url", // May contain passwords, don't leak it to the logs.
"socks5_enabled", // SOCKS5 options are deprecated.
"socks5_host",
@@ -602,7 +603,10 @@ async fn test_get_next_msgs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
let alice = TestContext::new_alice().await;
assert_eq!(alice.get_config(Config::Displayname).await?, None);
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Change the config circumventing the cache
// This simulates what the notification plugin on iOS might do
@@ -610,21 +614,24 @@ async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
alice
.sql
.execute(
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('displayname', 'Alice 2')",
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('show_emails', '0')",
(),
)
.await?;
// Alice's Delta Chat doesn't know about it yet:
assert_eq!(alice.get_config(Config::Displayname).await?, None);
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Starting IO will fail of course because no server settings are configured,
// but it should invalidate the caches:
alice.start_io().await;
assert_eq!(
alice.get_config(Config::Displayname).await?,
Some("Alice 2".to_string())
alice.get_config(Config::ShowEmails).await?,
Some("0".to_string())
);
Ok(())

View File

@@ -79,6 +79,16 @@ impl EncryptHelper {
Ok(ctext)
}
/// Signs the passed-in `mail` using the private key from `context`.
/// Returns the payload and the signature.
pub async fn sign(self, context: &Context, mail: &MimePart<'static>) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
let mut buffer = Vec::new();
mail.clone().write_part(&mut buffer)?;
let signature = pgp::pk_calc_signature(buffer, &sign_key)?;
Ok(signature)
}
}
/// Ensures a private key exists for the configured user.

View File

@@ -234,7 +234,8 @@ pub enum EventType {
/// Location of one or more contact has changed.
///
/// @param data1 (u32) contact_id of the contact for which the location has changed.
/// If the locations of several contacts have been changed, this parameter is set to `None`.
/// If the locations of several contacts have been changed,
/// eg. after calling dc_delete_all_locations(), this parameter is set to `None`.
LocationChanged(Option<ContactId>),
/// Inform about the configuration progress started by configure().

View File

@@ -287,6 +287,7 @@ impl MsgId {
mod tests {
use super::*;
use crate::chat::{self, Chat, forward_msgs, save_msgs};
use crate::config::Config;
use crate::constants;
use crate::contact::ContactId;
use crate::message::{MessengerMessage, Viewtype};
@@ -554,7 +555,13 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
async fn test_html_forwarding_encrypted() {
let mut tcm = TestContextManager::new();
// Alice receives a non-delta html-message
// (`ShowEmails=AcceptedContacts` lets Alice actually receive non-delta messages for known
// contacts, the contact is marked as known by creating a chat using `chat_with_contact()`)
let alice = &tcm.alice().await;
alice
.set_config(Config::ShowEmails, Some("1"))
.await
.unwrap();
let chat = alice
.create_chat_with_contact("", "sender@testrun.org")
.await;
@@ -572,6 +579,10 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
// receive the message on another device
let alice = &tcm.alice().await;
alice
.set_config(Config::ShowEmails, Some("0"))
.await
.unwrap();
let msg = alice.recv_msg(&msg).await;
assert_eq!(msg.chat_id, alice.get_self_chat().await.id);
assert_eq!(msg.get_from_id(), ContactId::SELF);

View File

@@ -489,7 +489,7 @@ impl Imap {
let session = match self.connect(context, configuring).await {
Ok(session) => session,
Err(err) => {
self.connectivity.set_err(context, format!("{err:#}"));
self.connectivity.set_err(context, &err);
return Err(err);
}
};
@@ -945,6 +945,29 @@ impl Session {
Ok(())
}
/// Deletes all messages from IMAP folder.
pub(crate) async fn delete_all_messages(
&mut self,
context: &Context,
folder: &str,
) -> Result<()> {
let transport_id = self.transport_id();
if self.select_with_uidvalidity(context, folder).await? {
self.add_flag_finalized_with_set("1:*", "\\Deleted").await?;
self.selected_folder_needs_expunge = true;
context
.sql
.execute(
"DELETE FROM imap WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?;
}
Ok(())
}
/// Moves batch of messages identified by their UID from the currently
/// selected folder to the target folder.
async fn move_message_batch(
@@ -2001,7 +2024,7 @@ pub(crate) async fn prefetch_should_download(
return Ok(false);
}
let should_download = !blocked_contact || maybe_ndn;
let should_download = (!blocked_contact) || maybe_ndn;
Ok(should_download)
}

View File

@@ -1,7 +1,7 @@
//! Cryptographic key module.
use std::collections::BTreeMap;
use std::fmt::{self, Write as _};
use std::fmt;
use std::io::Cursor;
use anyhow::{Context as _, Result, bail, ensure};
@@ -583,21 +583,6 @@ impl Fingerprint {
pub fn hex(&self) -> String {
hex::encode_upper(&self.0)
}
/// Make a human-readable fingerprint.
pub fn human_readable(&self) -> String {
let mut f = String::new();
// Split key into chunks of 4 with space and newline at 20 chars
for (i, c) in self.hex().chars().enumerate() {
if i > 0 && i % 20 == 0 {
writeln!(&mut f).ok();
} else if i > 0 && i % 4 == 0 {
write!(&mut f, " ").ok();
}
write!(&mut f, "{c}").ok();
}
f
}
}
impl From<pgp::types::Fingerprint> for Fingerprint {
@@ -614,6 +599,22 @@ impl fmt::Debug for Fingerprint {
}
}
/// Make a human-readable fingerprint.
impl fmt::Display for Fingerprint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Split key into chunks of 4 with space and newline at 20 chars
for (i, c) in self.hex().chars().enumerate() {
if i > 0 && i % 20 == 0 {
writeln!(f)?;
} else if i > 0 && i % 4 == 0 {
write!(f, " ")?;
}
write!(f, "{c}")?;
}
Ok(())
}
}
/// Parse a human-readable or otherwise formatted fingerprint.
impl std::str::FromStr for Fingerprint {
type Err = anyhow::Error;
@@ -889,7 +890,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
1, 2, 4, 8, 16, 32, 64, 128, 255, 1, 2, 4, 8, 16, 32, 64, 128, 255, 19, 20,
]);
assert_eq!(
fp.human_readable(),
fp.to_string(),
"0102 0408 1020 4080 FF01\n0204 0810 2040 80FF 1314"
);
}

View File

@@ -101,6 +101,7 @@ mod update_helper;
pub mod webxdc;
#[macro_use]
mod dehtml;
mod authres;
pub mod color;
pub mod html;
pub mod net;

View File

@@ -264,11 +264,15 @@ impl Kml {
/// Enables location streaming in chat identified by `chat_id` for `seconds` seconds.
#[expect(clippy::arithmetic_side_effects)]
pub async fn send_to_chat(context: &Context, chat_id: ChatId, seconds: i64) -> Result<()> {
pub async fn send_locations_to_chat(
context: &Context,
chat_id: ChatId,
seconds: i64,
) -> Result<()> {
ensure!(seconds >= 0);
ensure!(!chat_id.is_special());
let now = time();
let is_sending_locations_before = is_sending_to_chat(context, chat_id).await?;
let is_sending_locations_before = is_sending_locations_to_chat(context, Some(chat_id)).await?;
context
.sql
.execute(
@@ -301,49 +305,35 @@ pub async fn send_to_chat(context: &Context, chat_id: ChatId, seconds: i64) -> R
Ok(())
}
/// Returns whether any chat is sending locations.
pub async fn is_sending(context: &Context) -> Result<bool> {
context
.sql
.exists(
"SELECT COUNT(id) FROM chats WHERE locations_send_until>?",
(time(),),
)
.await
}
/// Returns whether `chat_id` is sending locations.
pub async fn is_sending_to_chat(context: &Context, chat_id: ChatId) -> Result<bool> {
context
.sql
.exists(
"SELECT COUNT(id) FROM chats WHERE id=? AND locations_send_until>?",
(chat_id, time()),
)
.await
}
/// Returns a list of chats in which location streaming is enabled.
async fn get_chats_with_location_streaming(context: &Context) -> Result<Vec<ChatId>> {
context
.sql
.query_map_vec(
"SELECT id FROM chats WHERE locations_send_until>?",
(time(),),
|row| {
let chat_id: ChatId = row.get(0)?;
Ok(chat_id)
},
)
.await
}
/// Stop sending locations in all chats.
pub async fn stop_sending(context: &Context) -> Result<()> {
for chat_id in get_chats_with_location_streaming(context).await? {
send_to_chat(context, chat_id, 0).await?;
}
Ok(())
/// Returns whether `chat_id` or any chat is sending locations.
///
/// If `chat_id` is `Some` only that chat is checked, otherwise returns `true` if any chat
/// is sending locations.
pub async fn is_sending_locations_to_chat(
context: &Context,
chat_id: Option<ChatId>,
) -> Result<bool> {
let exists = match chat_id {
Some(chat_id) => {
context
.sql
.exists(
"SELECT COUNT(id) FROM chats WHERE id=? AND locations_send_until>?;",
(chat_id, time()),
)
.await?
}
None => {
context
.sql
.exists(
"SELECT COUNT(id) FROM chats WHERE locations_send_until>?;",
(time(),),
)
.await?
}
};
Ok(exists)
}
/// Sets current location of the user device.
@@ -469,6 +459,13 @@ fn is_marker(txt: &str) -> bool {
}
}
/// Deletes all locations from the database.
pub async fn delete_all(context: &Context) -> Result<()> {
context.sql.execute("DELETE FROM locations;", ()).await?;
context.emit_location_changed(None).await?;
Ok(())
}
/// Deletes expired locations.
///
/// Only path locations are deleted.
@@ -498,7 +495,7 @@ pub(crate) async fn delete_expired(context: &Context, now: i64) -> Result<()> {
///
/// This function is used when a message is deleted
/// that has a corresponding `location_id`.
pub(crate) async fn delete_poi(context: &Context, location_id: u32) -> Result<()> {
pub(crate) async fn delete_poi_location(context: &Context, location_id: u32) -> Result<()> {
context
.sql
.execute(
@@ -510,7 +507,7 @@ pub(crate) async fn delete_poi(context: &Context, location_id: u32) -> Result<()
}
/// Deletes POI locations that don't have corresponding message anymore.
pub(crate) async fn delete_orphaned_poi(context: &Context) -> Result<()> {
pub(crate) async fn delete_orphaned_poi_locations(context: &Context) -> Result<()> {
context.sql.execute("
DELETE FROM locations
WHERE independent=1 AND id NOT IN
@@ -719,9 +716,9 @@ pub(crate) async fn save(
pub(crate) async fn location_loop(context: &Context, interrupt_receiver: Receiver<()>) {
loop {
let next_event = match maybe_send(context).await {
let next_event = match maybe_send_locations(context).await {
Err(err) => {
warn!(context, "location::maybe_send failed: {:#}", err);
warn!(context, "maybe_send_locations failed: {:#}", err);
Some(60) // Retry one minute later.
}
Ok(next_event) => next_event,
@@ -759,7 +756,7 @@ pub(crate) async fn location_loop(context: &Context, interrupt_receiver: Receive
/// Returns number of seconds until the next time location streaming for some chat ends
/// automatically.
#[expect(clippy::arithmetic_side_effects)]
async fn maybe_send(context: &Context) -> Result<Option<u64>> {
async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
let mut next_event: Option<u64> = None;
let now = time();
@@ -871,7 +868,7 @@ mod tests {
use crate::config::Config;
use crate::message::MessageState;
use crate::receive_imf::receive_imf;
use crate::test_utils::{ExpectedEvents, TestContext, TestContextManager};
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
#[test]
@@ -1054,7 +1051,7 @@ Content-Disposition: attachment; filename="location.kml"
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
send_to_chat(&alice, alice_chat.id, 1000).await?;
send_locations_to_chat(&alice, alice_chat.id, 1000).await?;
let sent = alice.pop_sent_msg().await;
let msg = bob.recv_msg(&sent).await;
assert_eq!(msg.text, "Location streaming enabled by alice@example.org.");
@@ -1103,13 +1100,10 @@ Content-Disposition: attachment; filename="location.kml"
.await?;
let alice_chat = alice.create_chat(bob).await;
// Bob needs the chat accepted so that "normal" messages from Alice trigger `IncomingMsg`.
// Location-only messages still must trigger `MsgsChanged`.
bob.create_chat(alice).await;
// Alice enables location streaming.
// Bob receives a message saying that Alice enabled location streaming.
send_to_chat(alice, alice_chat.id, 60).await?;
send_locations_to_chat(alice, alice_chat.id, 60).await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
// Alice gets new location from GPS.
@@ -1119,19 +1113,8 @@ Content-Disposition: attachment; filename="location.kml"
// 10 seconds later location sending stream manages to send location.
SystemTime::shift(Duration::from_secs(10));
delete_expired(alice, time()).await?;
maybe_send(alice).await?;
bob.evtracker.clear_events();
maybe_send_locations(alice).await?;
bob.recv_msg_opt(&alice.pop_sent_msg().await).await;
bob.evtracker
.get_matching_ex(
bob,
ExpectedEvents {
expected: |e| matches!(e, EventType::MsgsChanged { .. }),
unexpected: |e| matches!(e, EventType::IncomingMsg { .. }),
},
)
.await
.unwrap();
assert_eq!(get_range(alice, None, None, 0, 0).await?.len(), 1);
assert_eq!(get_range(bob, None, None, 0, 0).await?.len(), 1);

View File

@@ -70,7 +70,6 @@ pub struct EnteredImapLoginParam {
/// Folder to watch.
///
/// If empty, user has not entered anything and it shuold expand to "INBOX" later.
#[serde(default)]
pub folder: String,
/// Socket security.
@@ -139,11 +138,10 @@ pub struct EnteredLoginParam {
}
impl EnteredLoginParam {
/// Loads entered account settings
/// that were set by the deprecated `configured_*` configs.
/// Loads entered account settings.
///
/// This is only needed by tests and clients using the old CFFI API.
pub(crate) async fn load_legacy(context: &Context) -> Result<Self> {
/// This is a legacy API for loading from separate config parameters.
pub(crate) async fn load(context: &Context) -> Result<Self> {
let addr = context
.get_config(Config::Addr)
.await?
@@ -180,7 +178,7 @@ impl EnteredLoginParam {
// The setting is named `imap_certificate_checks`
// for backwards compatibility,
// but now it is a global setting applied to all protocols,
// while `smtp_certificate_checks` has been removed.
// while `smtp_certificate_checks` is ignored.
let certificate_checks = if let Some(certificate_checks) = context
.get_config_parsed::<i32>(Config::ImapCertificateChecks)
.await?
@@ -243,10 +241,7 @@ impl EnteredLoginParam {
/// Saves entered account settings,
/// so that they can be prefilled if the user wants to configure the server again.
///
/// This is needed in case a UI is not yet updated, and still uses `get_config("mail_pw")` etc.
/// in order to prefill the entered account settings.
pub(crate) async fn save_legacy(&self, context: &Context) -> Result<()> {
pub(crate) async fn save(&self, context: &Context) -> Result<()> {
context.set_config(Config::Addr, Some(&self.addr)).await?;
context
@@ -369,7 +364,7 @@ mod tests {
.await?;
t.set_config(Config::MailPw, Some("foobarbaz")).await?;
let param = EnteredLoginParam::load_legacy(t).await?;
let param = EnteredLoginParam::load(t).await?;
assert_eq!(param.addr, "alice@example.org");
assert_eq!(
param.certificate_checks,
@@ -378,13 +373,13 @@ mod tests {
t.set_config(Config::ImapCertificateChecks, Some("1"))
.await?;
let param = EnteredLoginParam::load_legacy(t).await?;
let param = EnteredLoginParam::load(t).await?;
assert_eq!(param.certificate_checks, EnteredCertificateChecks::Strict);
// Fail to load invalid settings, but do not panic.
t.set_config(Config::ImapCertificateChecks, Some("999"))
.await?;
assert!(EnteredLoginParam::load_legacy(t).await.is_err());
assert!(EnteredLoginParam::load(t).await.is_err());
Ok(())
}
@@ -412,7 +407,7 @@ mod tests {
certificate_checks: Default::default(),
oauth2: false,
};
param.save_legacy(&t).await?;
param.save(&t).await?;
assert_eq!(
t.get_config(Config::Addr).await?.unwrap(),
"alice@example.org"
@@ -421,7 +416,7 @@ mod tests {
assert_eq!(t.get_config(Config::SendPw).await?, None);
assert_eq!(t.get_config_int(Config::SendPort).await?, 2947);
assert_eq!(EnteredLoginParam::load_legacy(&t).await?, param);
assert_eq!(EnteredLoginParam::load(&t).await?, param);
Ok(())
}

View File

@@ -25,7 +25,7 @@ use crate::download::DownloadState;
use crate::ephemeral::{Timer as EphemeralTimer, start_ephemeral_timers_msgids};
use crate::events::EventType;
use crate::imap::markseen_on_imap_table;
use crate::location;
use crate::location::delete_poi_location;
use crate::log::warn;
use crate::mimeparser::{SystemMessage, parse_message_id};
use crate::param::{Param, Params};
@@ -529,7 +529,7 @@ impl Message {
FROM msgs m
LEFT JOIN chats c ON c.id=m.chat_id
LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
WHERE m.id=? AND chat_id!=3 -- DC_CHAT_ID_TRASH
WHERE m.id=? AND chat_id!=3
LIMIT 1",
(id,),
|row| {
@@ -739,7 +739,7 @@ impl Message {
/// at a position different from the self-location.
/// You should not call this function
/// if you want to bind the current self-location to a message;
/// this is done by [`location::set()`] and [`location::send_to_chat()`].
/// this is done by [`location::set()`] and [`send_locations_to_chat()`].
///
/// Typically results in the event [`LocationChanged`] with
/// `contact_id` set to [`ContactId::SELF`].
@@ -748,7 +748,7 @@ impl Message {
/// `longitude` is the East-west position of the location.
///
/// [`location::set()`]: crate::location::set
/// [`location::send_to_chat()`]: crate::location::send_to_chat
/// [`send_locations_to_chat()`]: crate::location::send_locations_to_chat
/// [`LocationChanged`]: crate::events::EventType::LocationChanged
pub fn set_location(&mut self, latitude: f64, longitude: f64) {
if latitude == 0.0 && longitude == 0.0 {
@@ -795,6 +795,12 @@ impl Message {
self.viewtype
}
/// Forces the message to **keep** [Viewtype::Sticker]
/// e.g the message will not be converted to a [Viewtype::Image].
pub fn force_sticker(&mut self) {
self.param.set_int(Param::ForceSticker, 1);
}
/// Returns the state of the message.
pub fn get_state(&self) -> MessageState {
self.state
@@ -1381,8 +1387,13 @@ pub enum MessageState {
/// IMAP and MDN may be sent.
InSeen = 16,
// Deprecated 2024-12-07. Removed 2026-04.
// OutPreparing = 18,
/// For files which need time to be prepared before they can be
/// sent, the message enters this state before
/// OutPending.
///
/// Deprecated 2024-12-07.
OutPreparing = 18,
/// Message saved as draft.
OutDraft = 19,
@@ -1415,6 +1426,7 @@ impl std::fmt::Display for MessageState {
Self::InFresh => "Fresh",
Self::InNoticed => "Noticed",
Self::InSeen => "Seen",
Self::OutPreparing => "Preparing",
Self::OutDraft => "Draft",
Self::OutPending => "Pending",
Self::OutFailed => "Failed",
@@ -1431,7 +1443,7 @@ impl MessageState {
use MessageState::*;
matches!(
self,
OutPending | OutDelivered | OutMdnRcvd // OutMdnRcvd can still fail because it could be a group message and only some recipients failed.
OutPreparing | OutPending | OutDelivered | OutMdnRcvd // OutMdnRcvd can still fail because it could be a group message and only some recipients failed.
)
}
@@ -1440,7 +1452,7 @@ impl MessageState {
use MessageState::*;
matches!(
self,
OutDraft | OutPending | OutFailed | OutDelivered | OutMdnRcvd
OutPreparing | OutDraft | OutPending | OutFailed | OutDelivered | OutMdnRcvd
)
}
@@ -1643,7 +1655,7 @@ pub(crate) async fn get_mime_headers(context: &Context, msg_id: MsgId) -> Result
/// This may be called in batches; the final events are emitted in delete_msgs_locally_done() then.
pub(crate) async fn delete_msg_locally(context: &Context, msg: &Message) -> Result<()> {
if msg.location_id > 0 {
location::delete_poi(context, msg.location_id).await?;
delete_poi_location(context, msg.location_id).await?;
}
let on_server = true;
msg.id
@@ -2111,6 +2123,7 @@ pub async fn get_request_msg_cnt(context: &Context) -> usize {
/// Count messages older than the given number of `seconds`.
///
/// Returns the number of messages that are older than the given number of seconds.
/// This includes e-mails downloaded due to the `show_emails` option.
/// Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
#[expect(clippy::arithmetic_side_effects)]
pub async fn estimate_deletion_cnt(
@@ -2309,6 +2322,8 @@ pub enum Viewtype {
Gif = 21,
/// Message containing a sticker, similar to image.
/// NB: When sending, the message viewtype may be changed to `Image` by some heuristics like
/// checking for transparent pixels. Use `Message::force_sticker()` to disable them.
///
/// If possible, the ui should display the image without borders in a transparent way.
/// A click on a sticker will offer to install the sticker set in some future.

View File

@@ -194,7 +194,6 @@ fn new_address_with_name(name: &str, address: String) -> Address<'static> {
}
impl MimeFactory {
/// Returns `MimeFactory` for rendering `msg`.
#[expect(clippy::arithmetic_side_effects)]
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let now = time();
@@ -1227,18 +1226,53 @@ impl MimeFactory {
message.header(header, value)
});
let message = MimePart::new("multipart/mixed", vec![message]);
let message = protected_headers
let mut message = protected_headers
.iter()
.fold(message, |message, (header, value)| {
message.header(*header, value.clone())
});
// Deduplicate unprotected headers that also are in the protected headers:
let protected: HashSet<&str> =
HashSet::from_iter(protected_headers.iter().map(|(header, _value)| *header));
unprotected_headers.retain(|(header, _value)| !protected.contains(header));
if skip_autocrypt || !context.get_config_bool(Config::SignUnencrypted).await? {
// Deduplicate unprotected headers that also are in the protected headers:
let protected: HashSet<&str> =
HashSet::from_iter(protected_headers.iter().map(|(header, _value)| *header));
unprotected_headers.retain(|(header, _value)| !protected.contains(header));
message
message
} else {
for (h, v) in &mut message.headers {
if h == "Content-Type"
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
{
let mut ct_new = ct.clone();
ct_new = ct_new.attribute("protected-headers", "v1");
if use_std_header_protection {
ct_new = ct_new.attribute("hp", "clear");
}
*ct = ct_new;
break;
}
}
let signature = encrypt_helper.sign(context, &message).await?;
MimePart::new(
"multipart/signed; protocol=\"application/pgp-signature\"; protected",
vec![
message,
MimePart::new(
"application/pgp-signature; name=\"signature.asc\"",
signature,
)
.header(
"Content-Description",
mail_builder::headers::raw::Raw::<'static>::new(
"OpenPGP digital signature",
),
)
.attachment("signature"),
],
)
}
};
let MimeFactory {
@@ -1360,7 +1394,10 @@ impl MimeFactory {
}
}
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
{
headers.push((
"Chat-Group-Name",
mail_builder::headers::text::Text::new(chat.name.to_string()).into(),
@@ -1371,11 +1408,7 @@ impl MimeFactory {
mail_builder::headers::text::Text::new(ts.to_string()).into(),
));
}
}
if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
{
match command {
SystemMessage::MemberRemovedFromGroup => {
let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default();
@@ -1794,7 +1827,7 @@ impl MimeFactory {
parts.push(msg_kml_part);
}
if location::is_sending_to_chat(context, msg.chat_id).await?
if location::is_sending_locations_to_chat(context, Some(msg.chat_id)).await?
&& let Some(part) = self.get_location_kml_part(context).await?
{
parts.push(part);
@@ -2157,6 +2190,10 @@ fn group_headers_by_confidentiality(
}
}
} else {
// Copy the header to the protected headers
// in case of signed-only message.
// If the message is not signed, this value will not be used.
protected_headers.push(header.clone());
unprotected_headers.push(header.clone())
}
}
@@ -2188,18 +2225,18 @@ fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool {
/// rather than all recipients.
/// This function returns the fingerprint of the recipient the message should be sent to.
fn must_have_only_one_recipient<'a>(msg: &'a Message, chat: &Chat) -> Option<Result<&'a str>> {
if chat.typ != Chattype::OutBroadcast {
None
} else if let Some(fp) = msg.param.get(Param::Arg4) {
Some(Ok(fp))
} else if matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup
) {
Some(Err(format_err!("Missing removed/added member")))
} else {
None
if chat.typ == Chattype::OutBroadcast
&& matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup
)
{
let Some(fp) = msg.param.get(Param::Arg4) else {
return Some(Err(format_err!("Missing removed/added member")));
};
return Some(Ok(fp));
}
None
}
async fn build_body_file(context: &Context, msg: &Message) -> Result<MimePart<'static>> {

View File

@@ -506,6 +506,11 @@ async fn msg_to_subject_str_inner(
// Creates a `Message` that replies "Hi" to the incoming email in `imf_raw`.
async fn incoming_msg_to_reply_msg(imf_raw: &[u8], context: &Context) -> Message {
context
.set_config(Config::ShowEmails, Some("2"))
.await
.unwrap();
receive_imf(context, imf_raw, false).await.unwrap();
let chats = Chatlist::try_load(context, 0, None, None).await.unwrap();
@@ -601,6 +606,70 @@ async fn test_selfavatar_unencrypted() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_selfavatar_unencrypted_signed() {
// create chat with bob, set selfavatar
let t = TestContext::new_alice().await;
t.set_config(Config::SignUnencrypted, Some("1"))
.await
.unwrap();
let chat = t.create_chat_with_contact("bob", "bob@example.org").await;
let file = t.dir.path().join("avatar.png");
let bytes = include_bytes!("../../test-data/image/avatar64x64.png");
tokio::fs::write(&file, bytes).await.unwrap();
t.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
.await
.unwrap();
// send message to bob: that should get multipart/signed.
// `Subject:` is protected by copying it.
// make sure, `Subject:` stays in the outer header (imf header)
let mut msg = Message::new_text("this is the text!".to_string());
let sent_msg = t.send_msg(chat.id, &mut msg).await;
let mut payload = sent_msg.payload().splitn(4, "\r\n\r\n");
let part = payload.next().unwrap();
assert_eq!(part.match_indices("multipart/signed").count(), 1);
assert_eq!(part.match_indices("From:").count(), 1);
assert_eq!(part.match_indices("Message-ID:").count(), 1);
assert_eq!(part.match_indices("Subject:").count(), 1);
assert_eq!(part.match_indices("Autocrypt:").count(), 1);
assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0);
let part = payload.next().unwrap();
assert_eq!(
part.match_indices("multipart/mixed; protected-headers=\"v1\"")
.count(),
1
);
assert_eq!(part.match_indices("From:").count(), 1);
assert_eq!(part.match_indices("Message-ID:").count(), 0);
assert_eq!(part.match_indices("Subject:").count(), 1);
assert_eq!(part.match_indices("Autocrypt:").count(), 1);
assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0);
let part = payload.next().unwrap();
assert_eq!(part.match_indices("text/plain").count(), 1);
assert_eq!(part.match_indices("From:").count(), 0);
assert_eq!(part.match_indices("Message-ID:").count(), 1);
assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0);
assert_eq!(part.match_indices("Subject:").count(), 0);
let body = payload.next().unwrap();
assert_eq!(body.match_indices("this is the text!").count(), 1);
let bob = TestContext::new_bob().await;
bob.recv_msg(&sent_msg).await;
let alice_id = Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.unwrap()
.unwrap();
let alice_contact = Contact::get_by_id(&bob.ctx, alice_id).await.unwrap();
assert_eq!(alice_contact.is_key_contact(), false);
}
/// Test that removed member address does not go into the `To:` field.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_remove_member_bcc() -> Result<()> {

View File

@@ -14,8 +14,9 @@ use mailparse::{DispositionType, MailHeader, MailHeaderMap, SingleInfo, addrpars
use mime::Mime;
use crate::aheader::Aheader;
use crate::authres::handle_authres;
use crate::blob::BlobObject;
use crate::chat::{Chat, ChatId};
use crate::chat::ChatId;
use crate::config::Config;
use crate::constants;
use crate::contact::{ContactId, import_public_key};
@@ -274,7 +275,7 @@ impl MimeMessage {
let timestamp_rcvd = smeared_time(context);
let mut timestamp_sent =
Self::get_timestamp_sent(&mail.headers, timestamp_rcvd, timestamp_rcvd);
let hop_info = parse_receive_headers(&mail.get_headers());
let mut hop_info = parse_receive_headers(&mail.get_headers());
let mut headers = Default::default();
let mut headers_removed = HashSet::<String>::new();
@@ -304,9 +305,37 @@ impl MimeMessage {
// Parse hidden headers.
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
let (part, mimetype) =
if mimetype.type_() == mime::MULTIPART && mimetype.subtype().as_str() == "signed" {
if let Some(part) = mail.subparts.first() {
// We don't remove "subject" from `headers` because currently just signed
// messages are shown as unencrypted anyway.
timestamp_sent =
Self::get_timestamp_sent(&part.headers, timestamp_sent, timestamp_rcvd);
MimeMessage::merge_headers(
context,
&mut headers,
&mut headers_removed,
&mut recipients,
&mut past_members,
&mut from,
&mut list_post,
&mut chat_disposition_notification_to,
part,
);
(part, part.ctype.mimetype.parse::<Mime>()?)
} else {
// Not a valid signed message, handle it as plaintext.
(&mail, mimetype)
}
} else {
// Currently we do not sign unencrypted messages by default.
(&mail, mimetype)
};
if mimetype.type_() == mime::MULTIPART
&& mimetype.subtype().as_str() == "mixed"
&& let Some(part) = mail.subparts.first()
&& let Some(part) = part.subparts.first()
{
for field in &part.headers {
let key = field.get_key().to_lowercase();
@@ -330,13 +359,18 @@ impl MimeMessage {
);
}
// Remove headers that are allowed _only_ in the encrypted+signed part
// Remove headers that are allowed _only_ in the encrypted+signed part. It's ok to leave
// them in signed-only emails, but has no value currently.
let encrypted = false;
Self::remove_secured_headers(&mut headers, &mut headers_removed, encrypted);
let mut from = from.context("No from in message")?;
let dkim_results = handle_authres(context, &mail, &from.addr).await?;
let mut gossiped_keys = Default::default();
hop_info += "\n\n";
hop_info += &dkim_results.to_string();
let from_is_not_self_addr = !context.is_self_addr(&from.addr).await?;
@@ -490,13 +524,11 @@ impl MimeMessage {
let mail = mail.as_ref().map(|mail| {
let (content, signatures_detached) = validate_detached_signature(mail, &public_keyring)
.unwrap_or((mail, Default::default()));
if is_encrypted {
let signatures_detached = signatures_detached
.into_iter()
.map(|fp| (fp, Vec::new()))
.collect::<HashMap<_, _>>();
signatures.extend(signatures_detached);
}
let signatures_detached = signatures_detached
.into_iter()
.map(|fp| (fp, Vec::new()))
.collect::<HashMap<_, _>>();
signatures.extend(signatures_detached);
content
});
@@ -2100,7 +2132,7 @@ async fn parse_gossip_headers(
let mut gossiped_keys: BTreeMap<String, GossipedKey> = Default::default();
for value in &gossip_headers {
let header = match Aheader::from_str(value) {
let header = match value.parse::<Aheader>() {
Ok(header) => header,
Err(err) => {
warn!(context, "Failed parsing Autocrypt-Gossip header: {}", err);
@@ -2190,6 +2222,9 @@ pub(crate) fn parse_message_id(ids: &str) -> Result<String> {
/// Returns whether the outer header value must be ignored if the message contains a signed (and
/// optionally encrypted) part. This is independent from the modern Header Protection defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html>.
///
/// NB: There are known cases when Subject and List-ID only appear in the outer headers of
/// signed-only messages. Such messages are shown as unencrypted anyway.
fn is_protected(key: &str) -> bool {
key.starts_with("chat-")
|| matches!(
@@ -2547,10 +2582,6 @@ async fn handle_ndn(
for msg_id in msg_ids {
let mut message = Message::load_from_db(context, msg_id).await?;
let chat = Chat::load_from_db(context, message.chat_id).await?;
if chat.typ == constants::Chattype::OutBroadcast {
continue;
}
let aggregated_error = message
.error
.as_ref()

View File

@@ -7,7 +7,6 @@ use crate::{
chat,
chatlist::Chatlist,
constants::{self, Blocked, DC_DESIRED_TEXT_LEN, DC_ELLIPSIS},
contact::Contact,
key,
message::{MessageState, MessengerMessage},
receive_imf::receive_imf,
@@ -2042,24 +2041,32 @@ async fn test_multiple_autocrypt_hdrs() -> Result<()> {
Ok(())
}
/// Tests receiving a simple signed-unencrypted message
/// that was generated by an old version of Core that supported sending such messages.
/// Tests that timestamp of signed but not encrypted message is protected.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_receive_signed_only() -> Result<()> {
async fn test_protected_date() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let imf_raw = include_bytes!("../../test-data/message/unencrypted_signed_simple.eml");
let msg = receive_imf(bob, imf_raw, false).await?.unwrap();
assert_eq!(msg.msg_ids.len(), 1);
let msg = Message::load_from_db(bob, msg.msg_ids[0]).await?;
assert_eq!(msg.get_text(), "Hello!");
assert_eq!(msg.viewtype, Viewtype::Text);
assert_eq!(msg.get_timestamp(), 1615987853);
alice.set_config(Config::SignUnencrypted, Some("1")).await?;
let alice_contact = Contact::get_by_id(bob, msg.from_id).await.unwrap();
assert_eq!(alice_contact.is_key_contact(), false);
let alice_chat = alice.create_email_chat(bob).await;
let alice_msg_id = chat::send_text_msg(alice, alice_chat.id, "Hello!".to_string()).await?;
let alice_msg = Message::load_from_db(alice, alice_msg_id).await?;
assert_eq!(alice_msg.get_showpadlock(), false);
let mut sent_msg = alice.pop_sent_msg().await;
sent_msg.payload = sent_msg.payload.replacen(
"Date:",
"Date: Wed, 17 Mar 2021 14:30:53 +0100 (CET)\r\nX-Not-Date:",
1,
);
let bob_msg = bob.recv_msg(&sent_msg).await;
assert_eq!(alice_msg.get_text(), bob_msg.get_text());
// Timestamp that the sender has put into the message
// should always be displayed as is on the receiver.
assert_eq!(alice_msg.get_timestamp(), bob_msg.get_timestamp());
Ok(())
}

View File

@@ -136,10 +136,6 @@ pub enum Param {
/// For "MemberAddedToGroup" and "MemberRemovedFromGroup",
/// this is the fingerprint added to / removed from the group.
///
/// For messages resent when adding a new member to a broadcast channel,
/// this is the fingerprint of the added member;
/// the message must only be sent to this one member then.
///
/// For call messages, this is the end timsetamp.
Arg4 = b'H',
@@ -247,6 +243,9 @@ pub enum Param {
/// For Webxdc Message Instances: Chat to integrate the Webxdc for.
WebxdcIntegrateFor = b'2',
/// For messages: Whether [crate::message::Viewtype::Sticker] should be forced.
ForceSticker = b'X',
/// For messages: Message is a deletion request. The value is a list of rfc724_mid of the messages to delete.
DeleteRequestFor = b'M',

View File

@@ -6,15 +6,15 @@ use std::io::Cursor;
use anyhow::{Context as _, Result, ensure};
use deltachat_contact_tools::{EmailAddress, may_be_valid_addr};
use pgp::composed::{
Deserializable, DetachedSignature, EncryptionCaps, KeyType as PgpKeyType, MessageBuilder,
SecretKeyParamsBuilder, SignedKeyDetails, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
SubkeyParamsBuilder, SubpacketConfig,
ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType as PgpKeyType,
MessageBuilder, SecretKeyParamsBuilder, SignedKeyDetails, SignedPublicKey, SignedPublicSubKey,
SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
};
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{Signature, Subpacket, SubpacketData};
use pgp::packet::{Signature, SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::types::{
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
StringToKey,
@@ -202,6 +202,47 @@ pub async fn pk_encrypt(
.await?
}
/// Produces a detached signature for `plain` text using `private_key_for_signing`.
pub fn pk_calc_signature(
plain: Vec<u8>,
private_key_for_signing: &SignedSecretKey,
) -> Result<String> {
let rng = thread_rng();
let mut config = SignatureConfig::from_key(
rng,
&private_key_for_signing.primary_key,
SignatureType::Binary,
)?;
config.hashed_subpackets = vec![
Subpacket::regular(SubpacketData::IssuerFingerprint(
private_key_for_signing.fingerprint(),
))?,
Subpacket::critical(SubpacketData::SignatureCreationTime(
pgp::types::Timestamp::now(),
))?,
];
config.unhashed_subpackets = vec![];
if private_key_for_signing.version() <= KeyVersion::V4 {
config
.unhashed_subpackets
.push(Subpacket::regular(SubpacketData::IssuerKeyId(
private_key_for_signing.legacy_key_id(),
))?);
}
let signature = config.sign(
&private_key_for_signing.primary_key,
&Password::empty(),
plain.as_slice(),
)?;
let sig = DetachedSignature::new(signature);
Ok(sig.to_armored_string(ArmorOptions::default())?)
}
/// Returns fingerprints
/// of all keys from the `public_keys_for_validation` keyring that
/// have valid signatures in `msg` and corresponding intended recipient fingerprints

View File

@@ -234,6 +234,34 @@ static P_BLUEWIN_CH: Provider = Provider {
oauth2_authorizer: None,
};
// buzon.uy.md: buzon.uy
static P_BUZON_UY: Provider = Provider {
id: "buzon.uy",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/buzon-uy",
server: &[
Server {
protocol: Imap,
socket: Starttls,
hostname: "mail.buzon.uy",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "mail.buzon.uy",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// chello.at.md: chello.at
static P_CHELLO_AT: Provider = Provider {
id: "chello.at",
@@ -275,6 +303,48 @@ static P_COMCAST: Provider = Provider {
oauth2_authorizer: None,
};
// daleth.cafe.md: daleth.cafe
static P_DALETH_CAFE: Provider = Provider {
id: "daleth.cafe",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/daleth-cafe",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "daleth.cafe",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "daleth.cafe",
port: 465,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Starttls,
hostname: "daleth.cafe",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "daleth.cafe",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// dismail.de.md: dismail.de
static P_DISMAIL_DE: Provider = Provider {
id: "dismail.de",
@@ -426,6 +496,22 @@ static P_FIREMAIL_DE: Provider = Provider {
oauth2_authorizer: None,
};
// five.chat.md: five.chat
static P_FIVE_CHAT: Provider = Provider {
id: "five.chat",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/five-chat",
server: &[],
opt: ProviderOptions::new(),
config_defaults: Some(&[ConfigDefault {
key: Config::BccSelf,
value: "1",
}]),
oauth2_authorizer: None,
};
// freenet.de.md: freenet.de
static P_FREENET_DE: Provider = Provider {
id: "freenet.de",
@@ -543,10 +629,16 @@ static P_HERMES_RADIO: Provider = Provider {
strict_tls: false,
..ProviderOptions::new()
},
config_defaults: Some(&[ConfigDefault {
key: Config::MdnsEnabled,
value: "0",
}]),
config_defaults: Some(&[
ConfigDefault {
key: Config::MdnsEnabled,
value: "0",
},
ConfigDefault {
key: Config::ShowEmails,
value: "2",
},
]),
oauth2_authorizer: None,
};
@@ -827,6 +919,90 @@ static P_MAILO_COM: Provider = Provider {
oauth2_authorizer: None,
};
// mehl.cloud.md: mehl.cloud
static P_MEHL_CLOUD: Provider = Provider {
id: "mehl.cloud",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/mehl-cloud",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "mehl.cloud",
port: 443,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "mehl.cloud",
port: 443,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Ssl,
hostname: "mehl.cloud",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "mehl.cloud",
port: 465,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Starttls,
hostname: "mehl.cloud",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "mehl.cloud",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// mehl.store.md: mehl.store, ende.in.net, l2i.top, szh.homes, sls.post.in, ente.quest, ente.cfd, nein.jetzt
static P_MEHL_STORE: Provider = Provider {
id: "mehl.store",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "This account provides 3GB storage for eMails and the possibility to access a NEXTCLOUD-instance by using the email-credits!",
overview_page: "https://providers.delta.chat/mehl-store",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "mail.ende.in.net",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "mail.ende.in.net",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// migadu.md: migadu.com
static P_MIGADU: Provider = Provider {
id: "migadu",
@@ -1074,8 +1250,8 @@ static P_OUVATON_COOP: Provider = Provider {
// posteo.md: posteo.de, posteo.af, posteo.at, posteo.be, posteo.ca, posteo.ch, posteo.cl, posteo.co, posteo.co.uk, posteo.com, posteo.com.br, posteo.cr, posteo.cz, posteo.dk, posteo.ee, posteo.es, posteo.eu, posteo.fi, posteo.gl, posteo.gr, posteo.hn, posteo.hr, posteo.hu, posteo.ie, posteo.in, posteo.is, posteo.it, posteo.jp, posteo.la, posteo.li, posteo.lt, posteo.lu, posteo.me, posteo.mx, posteo.my, posteo.net, posteo.nl, posteo.no, posteo.nz, posteo.org, posteo.pe, posteo.pl, posteo.pm, posteo.pt, posteo.ro, posteo.ru, posteo.se, posteo.sg, posteo.si, posteo.tn, posteo.uk, posteo.us
static P_POSTEO: Provider = Provider {
id: "posteo",
status: Status::Preparation,
before_login_hint: "You must create an app-specific password before you can log in.",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/posteo",
server: &[
@@ -1386,6 +1562,51 @@ static P_T_ONLINE: Provider = Provider {
oauth2_authorizer: None,
};
// testrun.md: testrun.org
static P_TESTRUN: Provider = Provider {
id: "testrun",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/testrun",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "testrun.org",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "testrun.org",
port: 465,
username_pattern: Email,
},
Server {
protocol: Imap,
socket: Starttls,
hostname: "testrun.org",
port: 143,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "testrun.org",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: Some(&[ConfigDefault {
key: Config::BccSelf,
value: "1",
}]),
oauth2_authorizer: None,
};
// tiscali.it.md: tiscali.it
static P_TISCALI_IT: Provider = Provider {
id: "tiscali.it",
@@ -1783,7 +2004,7 @@ static P_ZOHO: Provider = Provider {
oauth2_authorizer: None,
};
pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
pub(crate) static PROVIDER_DATA: [(&str, &Provider); 534] = [
("163.com", &P_163),
("aktivix.org", &P_AKTIVIX_ORG),
("aliyun.com", &P_ALIYUN),
@@ -1793,9 +2014,11 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("delta.blinzeln.de", &P_BLINDZELN_ORG),
("delta.blindzeln.org", &P_BLINDZELN_ORG),
("bluewin.ch", &P_BLUEWIN_CH),
("buzon.uy", &P_BUZON_UY),
("chello.at", &P_CHELLO_AT),
("xfinity.com", &P_COMCAST),
("comcast.net", &P_COMCAST),
("daleth.cafe", &P_DALETH_CAFE),
("dismail.de", &P_DISMAIL_DE),
("disroot.org", &P_DISROOT),
("e.email", &P_E_EMAIL),
@@ -1922,6 +2145,7 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("your-mail.com", &P_FASTMAIL),
("firemail.at", &P_FIREMAIL_DE),
("firemail.de", &P_FIREMAIL_DE),
("five.chat", &P_FIVE_CHAT),
("freenet.de", &P_FREENET_DE),
("gmail.com", &P_GMAIL),
("googlemail.com", &P_GMAIL),
@@ -2144,6 +2368,15 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("mailbox.org", &P_MAILBOX_ORG),
("secure.mailbox.org", &P_MAILBOX_ORG),
("mailo.com", &P_MAILO_COM),
("mehl.cloud", &P_MEHL_CLOUD),
("mehl.store", &P_MEHL_STORE),
("ende.in.net", &P_MEHL_STORE),
("l2i.top", &P_MEHL_STORE),
("szh.homes", &P_MEHL_STORE),
("sls.post.in", &P_MEHL_STORE),
("ente.quest", &P_MEHL_STORE),
("ente.cfd", &P_MEHL_STORE),
("nein.jetzt", &P_MEHL_STORE),
("migadu.com", &P_MIGADU),
("nauta.cu", &P_NAUTA_CU),
("naver.com", &P_NAVER),
@@ -2236,6 +2469,7 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 521] = [
("systemli.org", &P_SYSTEMLI_ORG),
("t-online.de", &P_T_ONLINE),
("magenta.de", &P_T_ONLINE),
("testrun.org", &P_TESTRUN),
("tiscali.it", &P_TISCALI_IT),
("tutanota.com", &P_TUTANOTA),
("tutanota.de", &P_TUTANOTA),
@@ -2318,8 +2552,10 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("autistici.org", &P_AUTISTICI_ORG),
("blindzeln.org", &P_BLINDZELN_ORG),
("bluewin.ch", &P_BLUEWIN_CH),
("buzon.uy", &P_BUZON_UY),
("chello.at", &P_CHELLO_AT),
("comcast", &P_COMCAST),
("daleth.cafe", &P_DALETH_CAFE),
("dismail.de", &P_DISMAIL_DE),
("disroot", &P_DISROOT),
("e.email", &P_E_EMAIL),
@@ -2327,6 +2563,7 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("example.com", &P_EXAMPLE_COM),
("fastmail", &P_FASTMAIL),
("firemail.de", &P_FIREMAIL_DE),
("five.chat", &P_FIVE_CHAT),
("freenet.de", &P_FREENET_DE),
("gmail", &P_GMAIL),
("gmx.net", &P_GMX_NET),
@@ -2344,6 +2581,8 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("mail2tor", &P_MAIL2TOR),
("mailbox.org", &P_MAILBOX_ORG),
("mailo.com", &P_MAILO_COM),
("mehl.cloud", &P_MEHL_CLOUD),
("mehl.store", &P_MEHL_STORE),
("migadu", &P_MIGADU),
("nauta.cu", &P_NAUTA_CU),
("naver", &P_NAVER),
@@ -2363,6 +2602,7 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
("systemausfall.org", &P_SYSTEMAUSFALL_ORG),
("systemli.org", &P_SYSTEMLI_ORG),
("t-online", &P_T_ONLINE),
("testrun", &P_TESTRUN),
("tiscali.it", &P_TISCALI_IT),
("tutanota", &P_TUTANOTA),
("ukr.net", &P_UKR_NET),
@@ -2382,4 +2622,4 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
});
pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> =
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2026, 4, 21).unwrap());
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2026, 1, 28).unwrap());

View File

@@ -645,7 +645,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
}
} else {
Ok(Qr::FprWithoutAddr {
fingerprint: fingerprint.human_readable(),
fingerprint: fingerprint.to_string(),
})
}
}

View File

@@ -388,7 +388,7 @@ async fn test_decode_openpgp_fingerprint() -> Result<()> {
bob,
&format!(
"OPENPGP4FPR:{}#a=alice@example.org",
alice_contact.fingerprint().unwrap().hex()
alice_contact.fingerprint().unwrap()
),
)
.await?;
@@ -709,7 +709,7 @@ async fn test_decode_dclogin_advanced_options() -> Result<()> {
assert_eq!(param.smtp.security, Socket::Plain);
// `sc` option is actually ignored and `ic` is used instead
// because `smtp_certificate_checks` has been removed.
// because `smtp_certificate_checks` is deprecated.
assert_eq!(param.certificate_checks, EnteredCertificateChecks::Strict);
Ok(())

View File

@@ -6,17 +6,33 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
use async_imap::types::{Quota, QuotaResource};
use crate::chat::add_device_msg_with_importance;
use crate::config::Config;
use crate::context::Context;
use crate::imap::session::Session as ImapSession;
use crate::log::warn;
use crate::message::Message;
use crate::tools::{self, time_elapsed};
use crate::{EventType, stock_str};
/// quota icon in connectivity is "yellow".
/// warn about a nearly full mailbox after this usage percentage is reached.
/// quota icon is "yellow".
pub const QUOTA_WARN_THRESHOLD_PERCENTAGE: u64 = 80;
/// quota icon in connectivity is "red".
/// warning again after this usage percentage is reached,
/// quota icon is "red".
pub const QUOTA_ERROR_THRESHOLD_PERCENTAGE: u64 = 95;
/// if quota is below this value (again),
/// QuotaExceeding is cleared.
///
/// This value should be a bit below QUOTA_WARN_THRESHOLD_PERCENTAGE to
/// avoid jittering and lots of warnings when quota is exactly at the warning threshold.
///
/// We do not repeat warnings on a daily base or so as some provider
/// providers report bad values and we would then spam the user.
pub const QUOTA_ALLCLEAR_PERCENTAGE: u64 = 75;
/// Server quota information with an update timestamp.
#[derive(Debug)]
pub struct QuotaInfo {
@@ -54,6 +70,37 @@ async fn get_unique_quota_roots_and_usage(
Ok(unique_quota_roots)
}
fn get_highest_usage<'t>(
unique_quota_roots: &'t BTreeMap<String, Vec<QuotaResource>>,
) -> Result<(u64, &'t String, &'t QuotaResource)> {
let mut highest: Option<(u64, &'t String, &QuotaResource)> = None;
for (name, resources) in unique_quota_roots {
for r in resources {
let usage_percent = r.get_usage_percentage();
match highest {
None => {
highest = Some((usage_percent, name, r));
}
Some((up, ..)) => {
if up <= usage_percent {
highest = Some((usage_percent, name, r));
}
}
};
}
}
highest.context("no quota_resource found, this is unexpected")
}
/// Checks if a quota warning is needed.
pub fn needs_quota_warning(curr_percentage: u64, warned_at_percentage: u64) -> bool {
(curr_percentage >= QUOTA_WARN_THRESHOLD_PERCENTAGE
&& warned_at_percentage < QUOTA_WARN_THRESHOLD_PERCENTAGE)
|| (curr_percentage >= QUOTA_ERROR_THRESHOLD_PERCENTAGE
&& warned_at_percentage < QUOTA_ERROR_THRESHOLD_PERCENTAGE)
}
impl Context {
/// Returns whether the quota value needs an update. If so, `update_recent_quota()` should be
/// called.
@@ -66,6 +113,12 @@ impl Context {
/// Updates `quota.recent`, sets `quota.modified` to the current time
/// and emits an event to let the UIs update connectivity view.
///
/// Moreover, once each time quota gets larger than `QUOTA_WARN_THRESHOLD_PERCENTAGE`,
/// a device message is added.
/// As the message is added only once, the user is not spammed
/// in case for some providers the quota is always at ~100%
/// and new space is allocated as needed.
pub(crate) async fn update_recent_quota(
&self,
session: &mut ImapSession,
@@ -81,6 +134,32 @@ impl Context {
Err(anyhow!(stock_str::not_supported_by_provider(self)))
};
if let Ok(quota) = &quota {
match get_highest_usage(quota) {
Ok((highest, _, _)) => {
if needs_quota_warning(
highest,
self.get_config_int(Config::QuotaExceeding).await? as u64,
) {
self.set_config_internal(
Config::QuotaExceeding,
Some(&highest.to_string()),
)
.await?;
let mut msg = Message::new_text(stock_str::quota_exceeding(self, highest));
add_device_msg_with_importance(self, None, Some(&mut msg), true).await?;
} else if highest <= QUOTA_ALLCLEAR_PERCENTAGE {
self.set_config_internal(Config::QuotaExceeding, None)
.await?;
}
}
Err(err) => warn!(
self,
"Transport {transport_id}: Cannot get highest quota usage: {err:#}"
),
}
}
self.quota.write().await.insert(
transport_id,
QuotaInfo {
@@ -100,10 +179,29 @@ mod tests {
use super::*;
use crate::test_utils::TestContextManager;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_needs_quota_warning() -> Result<()> {
assert!(!needs_quota_warning(0, 0));
assert!(!needs_quota_warning(10, 0));
assert!(!needs_quota_warning(70, 0));
assert!(!needs_quota_warning(75, 0));
assert!(!needs_quota_warning(79, 0));
assert!(needs_quota_warning(80, 0));
assert!(needs_quota_warning(81, 0));
assert!(!needs_quota_warning(85, 80));
assert!(!needs_quota_warning(85, 81));
assert!(needs_quota_warning(95, 82));
assert!(!needs_quota_warning(97, 95));
assert!(!needs_quota_warning(97, 96));
assert!(!needs_quota_warning(1000, 96));
Ok(())
}
#[expect(clippy::assertions_on_constants)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quota_thresholds() -> anyhow::Result<()> {
assert!(0 < QUOTA_WARN_THRESHOLD_PERCENTAGE);
assert!(QUOTA_ALLCLEAR_PERCENTAGE > 50);
assert!(QUOTA_ALLCLEAR_PERCENTAGE < QUOTA_WARN_THRESHOLD_PERCENTAGE);
assert!(QUOTA_WARN_THRESHOLD_PERCENTAGE < QUOTA_ERROR_THRESHOLD_PERCENTAGE);
assert!(QUOTA_ERROR_THRESHOLD_PERCENTAGE < 100);
Ok(())

View File

@@ -36,7 +36,10 @@ pub struct Reaction {
reaction: String,
}
impl Reaction {
// We implement From<&str> instead of std::str::FromStr, because
// FromStr requires error type and reaction parsing never returns an
// error.
impl From<&str> for Reaction {
/// Convert a `&str` into a `Reaction`.
/// Everything after the first whitespace is ignored.
///
@@ -48,7 +51,7 @@ impl Reaction {
/// reactions is not different from other kinds of spam attacks
/// such as sending large numbers of large messages, and should be
/// dealt with the same way, e.g. by blocking the user.
pub fn new(reaction: &str) -> Self {
fn from(reaction: &str) -> Self {
let reaction: &str = reaction
.split_ascii_whitespace()
.next()
@@ -58,7 +61,9 @@ impl Reaction {
reaction: reaction.to_string(),
}
}
}
impl Reaction {
/// Returns true if reaction contains no emoji.
pub fn is_empty(&self) -> bool {
self.reaction.is_empty()
@@ -207,7 +212,7 @@ pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) ->
let msg = Message::load_from_db(context, msg_id).await?;
let chat_id = msg.chat_id;
let reaction = Reaction::new(reaction);
let reaction: Reaction = reaction.into();
let mut reaction_msg = Message::new_text(reaction.as_str().to_string());
reaction_msg.set_reaction();
reaction_msg.in_reply_to = Some(msg.rfc724_mid);
@@ -277,7 +282,7 @@ pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result<React
|row| {
let contact_id: ContactId = row.get(0)?;
let reaction: String = row.get(1)?;
Ok((contact_id, Reaction::new(reaction.as_str())))
Ok((contact_id, Reaction::from(reaction.as_str())))
},
)
.await?;
@@ -356,32 +361,32 @@ mod tests {
#[test]
fn test_parse_reaction() {
// Check that basic set of emojis from RFC 9078 is supported.
assert_eq!(Reaction::new("👍").as_str(), "👍");
assert_eq!(Reaction::new("👎").as_str(), "👎");
assert_eq!(Reaction::new("😀").as_str(), "😀");
assert_eq!(Reaction::new("").as_str(), "");
assert_eq!(Reaction::new("😢").as_str(), "😢");
assert_eq!(Reaction::from("👍").as_str(), "👍");
assert_eq!(Reaction::from("👎").as_str(), "👎");
assert_eq!(Reaction::from("😀").as_str(), "😀");
assert_eq!(Reaction::from("").as_str(), "");
assert_eq!(Reaction::from("😢").as_str(), "😢");
// Empty string can be used to remove all reactions.
assert!(Reaction::new("").is_empty());
assert!(Reaction::from("").is_empty());
// Short strings can be used as emojis, could be used to add
// support for custom emojis via emoji shortcodes.
assert_eq!(Reaction::new(":deltacat:").as_str(), ":deltacat:");
assert_eq!(Reaction::from(":deltacat:").as_str(), ":deltacat:");
// Check that long strings are not valid emojis.
assert!(
Reaction::new(":foobarbazquuxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:").is_empty()
Reaction::from(":foobarbazquuxaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:").is_empty()
);
// Multiple reactions separated by spaces or tabs are not supported.
assert_eq!(Reaction::new("👍 ❤").as_str(), "👍");
assert_eq!(Reaction::new("👍\t").as_str(), "👍");
assert_eq!(Reaction::from("👍 ❤").as_str(), "👍");
assert_eq!(Reaction::from("👍\t").as_str(), "👍");
assert_eq!(Reaction::new("👍\t:foo: ❤").as_str(), "👍");
assert_eq!(Reaction::new("👍\t:foo: ❤").as_str(), "👍");
assert_eq!(Reaction::from("👍\t:foo: ❤").as_str(), "👍");
assert_eq!(Reaction::from("👍\t:foo: ❤").as_str(), "👍");
assert_eq!(Reaction::new("👍 👍").as_str(), "👍");
assert_eq!(Reaction::from("👍 👍").as_str(), "👍");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -577,7 +582,7 @@ Content-Disposition: reaction\n\
assert_eq!(chat_id, expected_chat_id);
assert_eq!(msg_id, expected_msg_id);
assert_eq!(contact_id, expected_contact_id);
assert_eq!(reaction, Reaction::new(expected_reaction));
assert_eq!(reaction, Reaction::from(expected_reaction));
}
_ => panic!("Unexpected event {event:?}."),
}

View File

@@ -3,7 +3,6 @@
use std::cmp;
use std::collections::{BTreeMap, BTreeSet};
use std::iter;
use std::str::FromStr as _;
use std::sync::LazyLock;
use anyhow::{Context as _, Result, ensure};
@@ -12,13 +11,14 @@ use deltachat_contact_tools::{
sanitize_single_line,
};
use mailparse::SingleInfo;
use num_traits::FromPrimitive;
use regex::Regex;
use crate::chat::{
self, Chat, ChatId, ChatIdBlocked, ChatVisibility, is_contact_in_chat, save_broadcast_secret,
};
use crate::config::Config;
use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX};
use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
use crate::contact::{self, Contact, ContactId, Origin, mark_contact_id_as_verified};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc_inner;
@@ -525,15 +525,52 @@ pub(crate) async fn receive_imf_inner(
"Receiving message {rfc724_mid_orig:?}, seen={seen}...",
);
// These checks must be done before processing of SecureJoin and other special messages.
// check, if the mail is already in our database.
// make sure, this check is done eg. before securejoin-processing.
let (replace_msg_id, replace_chat_id);
if mime_parser.pre_message == mimeparser::PreMessageMode::Post {
// Post-Message just replaces the attachment and modifies Params, not the whole message.
// This is done in the `handle_post_message` method.
} else if let Some(msg_id) = message::rfc724_mid_exists(context, rfc724_mid_orig).await? {
info!(
context,
"Message {rfc724_mid} is already in some chat or deleted."
);
replace_msg_id = None;
replace_chat_id = None;
} else if let Some(old_msg_id) = message::rfc724_mid_exists(context, rfc724_mid).await? {
// This code handles the download of old partial download stub messages
// It will be removed after a transitioning period,
// after we have released a few versions with pre-messages
replace_msg_id = Some(old_msg_id);
replace_chat_id = if let Some(msg) = Message::load_from_db_optional(context, old_msg_id)
.await?
.filter(|msg| msg.download_state() != DownloadState::Done)
{
// The message was partially downloaded before.
match mime_parser.pre_message {
PreMessageMode::Post | PreMessageMode::None => {
info!(context, "Message already partly in DB, replacing.");
Some(msg.chat_id)
}
PreMessageMode::Pre { .. } => {
info!(context, "Cannot replace pre-message with a pre-message");
None
}
}
} else {
// The message was already fully downloaded
// or cannot be loaded because it is deleted.
None
};
} else {
replace_msg_id = if rfc724_mid_orig == rfc724_mid {
None
} else {
message::rfc724_mid_exists(context, rfc724_mid_orig).await?
};
replace_chat_id = None;
}
if replace_chat_id.is_some() {
// Need to update chat id in the db.
} else if let Some(msg_id) = replace_msg_id {
info!(context, "Message is already downloaded.");
if mime_parser.incoming {
return Ok(None);
}
@@ -552,7 +589,7 @@ pub(crate) async fn receive_imf_inner(
msg_id.set_delivered(context).await?;
}
return Ok(None);
}
};
let prevent_rename = should_prevent_rename(&mime_parser);
@@ -602,7 +639,8 @@ pub(crate) async fn receive_imf_inner(
mime_parser.get_header(HeaderDef::References),
mime_parser.get_header(HeaderDef::InReplyTo),
)
.await?;
.await?
.filter(|p| Some(p.id) != replace_msg_id);
let mut chat_assignment =
decide_chat_assignment(context, &mime_parser, &parent_message, rfc724_mid, from_id).await?;
@@ -682,8 +720,20 @@ pub(crate) async fn receive_imf_inner(
MessengerMessage::No
};
let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?)
.unwrap_or_default();
let allow_creation = if mime_parser.decryption_error.is_some() {
false
} else if is_dc_message == MessengerMessage::No
&& !context.get_config_bool(Config::IsChatmail).await?
{
// the message is a classic email in a classic profile
// (in chatmail profiles, we always show all messages, because shared dc-mua usage is not supported)
match show_emails {
ShowEmails::Off | ShowEmails::AcceptedContacts => false,
ShowEmails::All => true,
}
} else {
!mime_parser.parts.iter().all(|part| part.is_reaction)
};
@@ -718,6 +768,7 @@ pub(crate) async fn receive_imf_inner(
rfc724_mid_orig,
from_id,
seen,
replace_msg_id,
prevent_rename,
chat_id,
chat_id_blocked,
@@ -806,6 +857,8 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
if transport_changed {
info!(context, "Primary transport changed to {from_addr:?}.");
context.sql.uncache_raw_config("configured_addr").await;
// Regenerate User ID in V4 keys.
context.self_public_key.lock().await.take();
context.emit_event(EventType::TransportsModified);
@@ -1010,6 +1063,11 @@ UPDATE msgs SET state=? WHERE
.await?;
} else if received_msg.hidden {
// No need to emit an event about the changed message
} else if let Some(replace_chat_id) = replace_chat_id {
match replace_chat_id == chat_id {
false => context.emit_msgs_changed_without_msg_id(replace_chat_id),
true => context.emit_msgs_changed(chat_id, replace_msg_id.unwrap_or_default()),
}
} else if !chat_id.is_trash() {
let fresh = received_msg.state == MessageState::InFresh
&& mime_parser.is_system_message != SystemMessage::CallAccepted
@@ -1017,15 +1075,8 @@ UPDATE msgs SET state=? WHERE
let is_bot = context.get_config_bool(Config::Bot).await?;
let is_pre_message = matches!(mime_parser.pre_message, PreMessageMode::Pre { .. });
let skip_bot_notify = is_bot && is_pre_message;
let is_empty = !is_pre_message
&& mime_parser.parts.first().is_none_or(|p| {
p.typ == Viewtype::Text && p.msg.is_empty() && p.param.get(Param::Quote).is_none()
});
let important = mime_parser.incoming
&& !is_empty
&& fresh
&& !is_old_contact_request
&& !skip_bot_notify;
let important =
mime_parser.incoming && fresh && !is_old_contact_request && !skip_bot_notify;
for msg_id in &received_msg.msg_ids {
chat_id.emit_msg_event(context, *msg_id, important);
@@ -1203,6 +1254,20 @@ async fn decide_chat_assignment(
}
info!(context, "Outgoing undecryptable message (TRASH).");
true
} else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage
&& !mime_parser.has_chat_version()
&& parent_message
.as_ref()
.is_none_or(|p| p.is_dc_message == MessengerMessage::No)
&& !context.get_config_bool(Config::IsChatmail).await?
&& ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?)
.unwrap_or_default()
== ShowEmails::Off
{
info!(context, "Classical email not shown (TRASH).");
// the message is a classic email in a classic profile
// (in chatmail profiles, we always show all messages, because shared dc-mua usage is not supported)
true
} else if mime_parser
.get_header(HeaderDef::XMozillaDraftInfo)
.is_some()
@@ -1743,6 +1808,7 @@ async fn add_parts(
rfc724_mid: &str,
from_id: ContactId,
seen: bool,
mut replace_msg_id: Option<MsgId>,
prevent_rename: bool,
mut chat_id: ChatId,
mut chat_id_blocked: Blocked,
@@ -1834,7 +1900,7 @@ async fn add_parts(
// Extract ephemeral timer from the message
let mut ephemeral_timer = if let Some(value) = mime_parser.get_header(HeaderDef::EphemeralTimer)
{
match EphemeralTimer::from_str(value) {
match value.parse::<EphemeralTimer>() {
Ok(timer) => timer,
Err(err) => {
warn!(context, "Can't parse ephemeral timer \"{value}\": {err:#}.");
@@ -2112,7 +2178,7 @@ async fn add_parts(
chat_id,
from_id,
sort_timestamp,
Reaction::new(reaction_str.as_str()),
Reaction::from(reaction_str.as_str()),
is_incoming_fresh,
)
.await?;
@@ -2123,6 +2189,22 @@ async fn add_parts(
param.set_int(Param::Cmd, is_system_message as i32);
}
if let Some(replace_msg_id) = replace_msg_id {
let placeholder = Message::load_from_db(context, replace_msg_id)
.await
.context("Failed to load placeholder message")?;
for key in [
Param::WebxdcSummary,
Param::WebxdcSummaryTimestamp,
Param::WebxdcDocument,
Param::WebxdcDocumentTimestamp,
] {
if let Some(value) = placeholder.param.get(key) {
param.set(key, value);
}
}
}
let (msg, typ): (&str, Viewtype) = if let Some(better_msg) = &better_msg {
(better_msg, Viewtype::Text)
} else {
@@ -2165,9 +2247,10 @@ async fn add_parts(
.sql
.call_write(|conn| {
let mut stmt = conn.prepare_cached(
"
r#"
INSERT INTO msgs
(
id,
rfc724_mid, pre_rfc724_mid, chat_id,
from_id, to_id, timestamp, timestamp_sent,
timestamp_rcvd, type, state, msgrmsg,
@@ -2177,29 +2260,34 @@ INSERT INTO msgs
ephemeral_timestamp, download_state, hop_info
)
VALUES (
?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?, ?, 1,
?, ?, ?, ?,
?, ?, ?, ?
)",
)?;
let params = params![
if let PreMessageMode::Pre {
post_msg_rfc724_mid,
..
} = &mime_parser.pre_message
{
)
ON CONFLICT (id) DO UPDATE
SET rfc724_mid=excluded.rfc724_mid, chat_id=excluded.chat_id,
from_id=excluded.from_id, to_id=excluded.to_id, timestamp_sent=excluded.timestamp_sent,
type=excluded.type, state=max(state,excluded.state), msgrmsg=excluded.msgrmsg,
txt=excluded.txt, txt_normalized=excluded.txt_normalized, subject=excluded.subject,
param=excluded.param,
hidden=excluded.hidden,bytes=excluded.bytes, mime_headers=excluded.mime_headers,
mime_compressed=excluded.mime_compressed, mime_in_reply_to=excluded.mime_in_reply_to,
mime_references=excluded.mime_references, mime_modified=excluded.mime_modified, error=excluded.error, ephemeral_timer=excluded.ephemeral_timer,
ephemeral_timestamp=excluded.ephemeral_timestamp, download_state=excluded.download_state, hop_info=excluded.hop_info
RETURNING id
"#)?;
let row_id: MsgId = stmt.query_row(params![
replace_msg_id,
if let PreMessageMode::Pre {post_msg_rfc724_mid, ..} = &mime_parser.pre_message {
post_msg_rfc724_mid
} else {
} else { rfc724_mid_orig },
if let PreMessageMode::Pre {..} = &mime_parser.pre_message {
rfc724_mid_orig
},
if let PreMessageMode::Pre { .. } = &mime_parser.pre_message {
rfc724_mid_orig
} else {
""
},
} else { "" },
if trash { DC_CHAT_ID_TRASH } else { chat_id },
if trash { ContactId::UNDEFINED } else { from_id },
if trash { ContactId::UNDEFINED } else { to_id },
@@ -2208,27 +2296,13 @@ INSERT INTO msgs
if trash { 0 } else { mime_parser.timestamp_rcvd },
if trash {
Viewtype::Unknown
} else if let PreMessageMode::Pre { .. } = mime_parser.pre_message {
} else if let PreMessageMode::Pre {..} = mime_parser.pre_message {
Viewtype::Text
} else {
typ
},
if trash {
MessageState::Undefined
} else {
state
},
if trash {
MessengerMessage::No
} else {
is_dc_message
},
} else { typ },
if trash { MessageState::Undefined } else { state },
if trash { MessengerMessage::No } else { is_dc_message },
if trash || hidden { "" } else { msg },
if trash || hidden {
None
} else {
normalize_text(msg)
},
if trash || hidden { None } else { normalize_text(msg) },
if trash || hidden { "" } else { &subject },
if trash {
"".to_string()
@@ -2245,28 +2319,33 @@ INSERT INTO msgs
if trash { "" } else { mime_in_reply_to },
if trash { "" } else { mime_references },
!trash && save_mime_modified,
if trash {
""
} else {
part.error.as_deref().unwrap_or_default()
},
if trash { "" } else { part.error.as_deref().unwrap_or_default() },
if trash { 0 } else { ephemeral_timer.to_u32() },
if trash { 0 } else { ephemeral_timestamp },
if trash {
DownloadState::Done
} else if mime_parser.decryption_error.is_some() {
DownloadState::Undecipherable
} else if let PreMessageMode::Pre { .. } = mime_parser.pre_message {
} else if let PreMessageMode::Pre {..} = mime_parser.pre_message {
DownloadState::Available
} else {
DownloadState::Done
},
if trash { "" } else { &mime_parser.hop_info },
];
let row_id = MsgId::new(stmt.insert(params)?.try_into()?);
],
|row| {
let msg_id: MsgId = row.get(0)?;
Ok(msg_id)
}
)?;
Ok(row_id)
})
.await?;
// We only replace placeholder with a first part,
// afterwards insert additional parts.
replace_msg_id = None;
ensure_and_debug_assert!(!row_id.is_special(), "Rowid {row_id} is special");
created_db_entries.push(row_id);
}
@@ -2291,6 +2370,14 @@ INSERT INTO msgs
.await?;
}
if let Some(replace_msg_id) = replace_msg_id {
// Trash the "replace" placeholder with a message that has no parts. If it has the original
// "Message-ID", mark the placeholder for server-side deletion so as if the user deletes the
// fully downloaded message later, the server-side deletion is issued.
let on_server = rfc724_mid == rfc724_mid_orig;
replace_msg_id.trash(context, on_server).await?;
}
let unarchive = match mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
Some(addr) => context.is_self_addr(addr).await?,
None => true,
@@ -2394,7 +2481,6 @@ async fn handle_edit_delete(
let rfc724_mid_vec: Vec<&str> = rfc724_mid_list.split_whitespace().collect();
for rfc724_mid in rfc724_mid_vec {
let rfc724_mid = rfc724_mid.trim_start_matches('<').trim_end_matches('>');
if let Some(msg_id) = message::rfc724_mid_exists(context, rfc724_mid).await? {
if let Some(msg) = Message::load_from_db_optional(context, msg_id).await? {
if msg.from_id == from_id {
@@ -2409,8 +2495,6 @@ async fn handle_edit_delete(
}
} else {
warn!(context, "Delete message: {rfc724_mid:?} not found.");
// Insert a tombstone so that the message will be ignored if it arrives later within a period specified in prune_tombstones().
insert_tombstone(context, rfc724_mid).await?;
}
}
message::delete_msgs_locally_done(context, &msg_ids, modified_chat_ids).await?;

View File

@@ -16,7 +16,7 @@ use crate::imex::{ImexMode, imex};
use crate::key;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{
TestContext, TestContextManager, alice_keypair, get_chat_msg, mark_as_verified,
E2EE_INFO_MSGS, TestContext, TestContextManager, alice_keypair, get_chat_msg, mark_as_verified,
};
use crate::tools::{SystemTime, time};
@@ -78,8 +78,9 @@ static GRP_MAIL: &[u8] =
hello\n";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_is_shown() {
async fn test_adhoc_group_show_chats_only() {
let t = TestContext::new_alice().await;
t.set_config(Config::ShowEmails, Some("0")).await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
@@ -94,12 +95,66 @@ async fn test_adhoc_group_is_shown() {
receive_imf(&t, GRP_MAIL, false).await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 2);
assert_eq!(chats.len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_show_accepted_contact_unknown() {
let t = TestContext::new_alice().await;
t.set_config(Config::ShowEmails, Some("1")).await.unwrap();
receive_imf(&t, GRP_MAIL, false).await.unwrap();
// adhoc-group with unknown contacts with show_emails=accepted is ignored for unknown contacts
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_outgoing_show_accepted_contact_unaccepted() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config(
Config::ShowEmails,
Some(&ShowEmails::AcceptedContacts.to_string()),
)
.await?;
tcm.send_recv(alice, bob, "hi").await;
receive_imf(
bob,
b"From: bob@example.net\n\
To: alice@example.org, claire@example.com\n\
Message-ID: <3333@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
false,
)
.await?;
let chats = Chatlist::try_load(bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0)?;
assert_eq!(chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_show_accepted_contact_known() {
let t = TestContext::new_alice().await;
t.set_config(Config::ShowEmails, Some("1")).await.unwrap();
Contact::create(&t, "Bob", "bob@example.com").await.unwrap();
receive_imf(&t, GRP_MAIL, false).await.unwrap();
// adhoc-group with known contacts with show_emails=accepted is still ignored for known contacts
// (and existent chat is required)
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_show_accepted_contact_accepted() {
let t = TestContext::new_alice().await;
t.set_config(Config::ShowEmails, Some("1")).await.unwrap();
// accept Bob by accepting a delta-message from Bob
receive_imf(&t, MSGRMSG, false).await.unwrap();
@@ -135,6 +190,7 @@ async fn test_adhoc_group_show_accepted_contact_accepted() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_show_all() {
let t = TestContext::new_alice().await;
assert_eq!(t.get_config_int(Config::ShowEmails).await.unwrap(), 2);
receive_imf(&t, GRP_MAIL, false).await.unwrap();
// adhoc-group with unknown contacts with show_emails=all will show up in a single chat
@@ -760,6 +816,10 @@ async fn test_concat_multiple_ndns() -> Result<()> {
}
async fn load_imf_email(context: &Context, imf_raw: &[u8]) -> Message {
context
.set_config(Config::ShowEmails, Some("2"))
.await
.unwrap();
let received_msg = receive_imf(context, imf_raw, false)
.await
.expect("receive_imf failure")

View File

@@ -250,6 +250,16 @@ impl SchedulerState {
}
}
pub(crate) async fn clear_all_relay_storage(&self) -> Result<()> {
let inner = self.inner.read().await;
if let InnerSchedulerState::Started(ref scheduler) = *inner {
scheduler.clear_all_relay_storage();
Ok(())
} else {
bail!("IO is not started");
}
}
pub(crate) async fn interrupt_smtp(&self) {
let inner = self.inner.read().await;
if let InnerSchedulerState::Started(ref scheduler) = *inner {
@@ -348,6 +358,7 @@ async fn inbox_loop(
let ImapConnectionHandlers {
mut connection,
stop_token,
clear_storage_request_receiver,
} = inbox_handlers;
let transport_id = connection.transport_id();
@@ -386,7 +397,14 @@ async fn inbox_loop(
}
};
match inbox_fetch_idle(&ctx, &mut connection, session).await {
match inbox_fetch_idle(
&ctx,
&mut connection,
session,
&clear_storage_request_receiver,
)
.await
{
Err(err) => warn!(
ctx,
"Transport {transport_id}: Failed inbox fetch_idle: {err:#}."
@@ -407,11 +425,29 @@ async fn inbox_loop(
.await;
}
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
async fn inbox_fetch_idle(
ctx: &Context,
imap: &mut Imap,
mut session: Session,
clear_storage_request_receiver: &Receiver<()>,
) -> Result<Session> {
let transport_id = session.transport_id();
// Clear IMAP storage on request.
//
// Only doing this for chatmail relays to avoid
// accidentally deleting all emails in a shared mailbox.
let should_clear_imap_storage =
clear_storage_request_receiver.try_recv().is_ok() && session.is_chatmail();
if should_clear_imap_storage {
info!(ctx, "Transport {transport_id}: Clearing IMAP storage.");
session.delete_all_messages(ctx, &imap.folder).await?;
}
// Update quota no more than once a minute.
if ctx.quota_needs_update(session.transport_id(), 60).await
//
// Always update if we just cleared IMAP storage.
if (ctx.quota_needs_update(session.transport_id(), 60).await || should_clear_imap_storage)
&& let Err(err) = ctx.update_recent_quota(&mut session, &imap.folder).await
{
warn!(
@@ -438,6 +474,11 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
last_housekeeping_time.saturating_add(constants::HOUSEKEEPING_PERIOD);
if next_housekeeping_time <= time() {
sql::housekeeping(ctx).await.log_err(ctx).ok();
} else {
let force_truncate = false;
if let Err(err) = ctx.sql.wal_checkpoint(ctx, force_truncate).await {
warn!(ctx, "wal_checkpoint() failed: {err:#}.");
}
}
}
Err(err) => {
@@ -595,7 +636,7 @@ async fn smtp_loop(
info!(ctx, "SMTP fake idle started.");
match &connection.last_send_error {
None => connection.connectivity.set_idle(&ctx),
Some(err) => connection.connectivity.set_err(&ctx, err.clone()),
Some(err) => connection.connectivity.set_err(&ctx, err),
}
// If send_smtp_messages() failed, we set a timeout for the fake-idle so that
@@ -737,6 +778,12 @@ impl Scheduler {
}
}
fn clear_all_relay_storage(&self) {
for b in &self.inboxes {
b.conn_state.clear_relay_storage();
}
}
fn interrupt_smtp(&self) {
self.smtp.interrupt();
}
@@ -870,6 +917,13 @@ struct SmtpConnectionHandlers {
#[derive(Debug)]
pub(crate) struct ImapConnectionState {
state: ConnectionState,
/// Channel to request clearing the folder.
///
/// IMAP loop receiving this should clear the folder
/// on the next iteration if IMAP server is a chatmail relay
/// and otherwise ignore the request.
clear_storage_request_sender: Sender<()>,
}
impl ImapConnectionState {
@@ -881,11 +935,13 @@ impl ImapConnectionState {
) -> Result<(Self, ImapConnectionHandlers)> {
let stop_token = CancellationToken::new();
let (idle_interrupt_sender, idle_interrupt_receiver) = channel::bounded(1);
let (clear_storage_request_sender, clear_storage_request_receiver) = channel::bounded(1);
let handlers = ImapConnectionHandlers {
connection: Imap::new(context, transport_id, login_param, idle_interrupt_receiver)
.await?,
stop_token: stop_token.clone(),
clear_storage_request_receiver,
};
let state = ConnectionState {
@@ -894,7 +950,10 @@ impl ImapConnectionState {
connectivity: handlers.connection.connectivity.clone(),
};
let conn = ImapConnectionState { state };
let conn = ImapConnectionState {
state,
clear_storage_request_sender,
};
Ok((conn, handlers))
}
@@ -908,10 +967,19 @@ impl ImapConnectionState {
fn stop(&self) {
self.state.stop();
}
/// Requests clearing relay storage and interrupts the inbox.
fn clear_relay_storage(&self) {
self.clear_storage_request_sender.try_send(()).ok();
self.state.interrupt();
}
}
#[derive(Debug)]
struct ImapConnectionHandlers {
connection: Imap,
stop_token: CancellationToken,
/// Channel receiver to get requests to clear IMAP storage.
pub(crate) clear_storage_request_receiver: Receiver<()>,
}

View File

@@ -157,8 +157,8 @@ impl ConnectivityStore {
context.emit_event(EventType::ConnectivityChanged);
}
pub(crate) fn set_err(&self, context: &Context, e: String) {
self.set(context, DetailedConnectivity::Error(e));
pub(crate) fn set_err(&self, context: &Context, e: impl ToString) {
self.set(context, DetailedConnectivity::Error(e.to_string()));
}
pub(crate) fn set_connecting(&self, context: &Context) {
self.set(context, DetailedConnectivity::Connecting);

View File

@@ -142,7 +142,7 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
let auth = create_id();
token::save(context, Namespace::Auth, grpid, &auth, time()).await?;
let fingerprint = self_fingerprint(context).await?;
let fingerprint = get_self_fingerprint(context).await?.hex();
let self_addr = context.get_primary_self_addr().await?;
let self_addr_urlencoded = utf8_percent_encode(&self_addr, DISALLOWED_CHARACTERS).to_string();
@@ -861,8 +861,7 @@ fn encrypted_and_signed(
} else {
warn!(
context,
"Message does not match expected fingerprint {}.",
expected_fingerprint.human_readable()
"Message does not match expected fingerprint {expected_fingerprint}.",
);
false
}

View File

@@ -15,7 +15,7 @@ use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
use crate::ephemeral::start_ephemeral_timers;
use crate::imex::BLOBS_BACKUP_NAME;
use crate::location;
use crate::location::delete_orphaned_poi_locations;
use crate::log::{LogExt, warn};
use crate::message::MsgId;
use crate::net::dns::prune_dns_cache;
@@ -261,6 +261,12 @@ impl Sql {
info!(context, "Opened database {:?}.", self.dbfile);
*self.is_encrypted.write().await = Some(passphrase_nonempty);
// Some migrations want housekeeping to run. Also if housekeeping failed before, fixing the
// reason and restarting the program is the most natural way to retry it.
context
.set_config_internal(Config::LastHousekeeping, None)
.await?;
// setup debug logging if there is an entry containing its id
if let Some(xdc_id) = self
.get_raw_config_u32(Config::DebugLogging.as_ref())
@@ -660,8 +666,16 @@ impl Sql {
&self.config_cache
}
/// Attempts to truncate the WAL file.
pub(crate) async fn wal_checkpoint(&self, context: &Context) -> Result<()> {
/// Runs a WAL checkpoint operation.
///
/// * `force_truncate` - Force TRUNCATE mode to truncate the WAL file to 0 bytes, otherwise only
/// run PASSIVE mode if the WAL isn't too large. NB: Truncating blocks all db connections for
/// some time.
pub(crate) async fn wal_checkpoint(
&self,
context: &Context,
force_truncate: bool,
) -> Result<()> {
let lock = self.pool.read().await;
let Some(pool) = lock.as_ref() else {
// No db connections, nothing to checkpoint.
@@ -674,7 +688,7 @@ impl Sql {
readers_blocked_duration,
pages_total,
pages_checkpointed,
} = pool.wal_checkpoint().await?;
} = pool.wal_checkpoint(force_truncate).await?;
if pages_checkpointed < pages_total {
warn!(
context,
@@ -705,6 +719,7 @@ fn new_connection(path: &Path, passphrase: &str) -> Result<Connection> {
PRAGMA secure_delete=on;
PRAGMA soft_heap_limit = 8388608; -- 8 MiB limit, same as set in Android SQLiteDatabase.
PRAGMA foreign_keys=on;
PRAGMA wal_autocheckpoint=N;
",
)?;
@@ -834,7 +849,8 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
// bigger than 200M) and also make sure we truncate the WAL periodically. Auto-checkponting does
// not normally truncate the WAL (unless the `journal_size_limit` pragma is set), see
// https://www.sqlite.org/wal.html.
if let Err(err) = Sql::wal_checkpoint(&context.sql, context).await {
let force_truncate = true;
if let Err(err) = Sql::wal_checkpoint(&context.sql, context, force_truncate).await {
warn!(context, "wal_checkpoint() failed: {err:#}.");
debug_assert!(false);
}
@@ -902,7 +918,7 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
// Delete POI locations
// which don't have corresponding message.
location::delete_orphaned_poi(context)
delete_orphaned_poi_locations(context)
.await
.context("Failed to delete orphaned POI locations")
.log_err(context)

View File

@@ -12,6 +12,7 @@ use rusqlite::OptionalExtension;
use crate::config::Config;
use crate::configure::EnteredLoginParam;
use crate::constants::ShowEmails;
use crate::context::Context;
use crate::key::DcKey;
use crate::log::warn;
@@ -974,7 +975,8 @@ ALTER TABLE msgs ADD COLUMN mime_references TEXT;"#,
// keep this default and use DC_SHOW_EMAILS_NO
// only for new installations
if exists_before_update {
sql.set_raw_config_int("show_emails", 2).await?;
sql.set_raw_config_int("show_emails", ShowEmails::All as i32)
.await?;
}
sql.set_db_version(50).await?;
}
@@ -1455,7 +1457,8 @@ CREATE INDEX smtp_messageid ON imap(rfc724_mid);
}
if dbversion < 98 {
if exists_before_update && sql.get_raw_config_int("show_emails").await?.is_none() {
sql.set_raw_config_int("show_emails", 0).await?;
sql.set_raw_config_int("show_emails", ShowEmails::Off as i32)
.await?;
}
sql.set_db_version(98).await?;
}
@@ -1916,7 +1919,7 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
inc_and_check(&mut migration_version, 131)?;
if dbversion < migration_version {
let entered_param = EnteredLoginParam::load_legacy(context).await?;
let entered_param = EnteredLoginParam::load(context).await?;
let configured_param = ConfiguredLoginParam::load_legacy(context).await?;
sql.execute_migration_transaction(
@@ -2373,18 +2376,6 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
.await?;
}
inc_and_check(&mut migration_version, 152)?;
if dbversion < migration_version {
sql.execute_migration(
"
UPDATE msgs SET state=26 WHERE state=28; -- Change OutMdnRcvd to OutDelivered.
UPDATE msgs SET state=19 WHERE state=24; -- Change OutPreparing to OutFailed.
",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -199,8 +199,8 @@ impl Pool {
Arc::clone(&self.inner).get(query_only).await
}
/// Truncates the WAL file.
pub(crate) async fn wal_checkpoint(&self) -> Result<WalCheckpointStats> {
wal_checkpoint::wal_checkpoint(self).await
/// Runs a WAL checkpoint operation.
pub(crate) async fn wal_checkpoint(&self, force_truncate: bool) -> Result<WalCheckpointStats> {
wal_checkpoint::wal_checkpoint(self, force_truncate).await
}
}

View File

@@ -26,26 +26,45 @@ pub(crate) struct WalCheckpointStats {
/// Number of checkpointed WAL pages.
///
/// It should be the same as `pages_total`
/// If TRUNCATE is forced, it should be the same as `pages_total`
/// unless there are external connections to the database
/// that are not in the pool.
pub pages_checkpointed: i64,
}
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
/// Runs a WAL checkpoint operation.
///
/// * `force_truncate` - Force TRUNCATE mode to truncate the WAL file to 0 bytes, otherwise only run
/// PASSIVE mode if the WAL isn't too large.
pub(super) async fn wal_checkpoint(
pool: &Pool,
force_truncate: bool,
) -> Result<WalCheckpointStats> {
let t_start = Time::now();
// Do as much work as possible without blocking anybody.
let query_only = true;
let conn = pool.get(query_only).await?;
tokio::task::block_in_place(|| {
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
// Execute some transaction causing the WAL file to be opened so that the
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| {
let pages_total: i64 = row.get(1)?;
let pages_checkpointed: i64 = row.get(2)?;
Ok((pages_total, pages_checkpointed))
})
})?;
if !force_truncate && pages_total < 4096 {
return Ok(WalCheckpointStats {
total_duration: time_elapsed(&t_start),
writers_blocked_duration: Duration::ZERO,
readers_blocked_duration: Duration::ZERO,
pages_total,
pages_checkpointed,
});
}
// Kick out writers. `write_mutex` should be locked before taking an `InnerPool.semaphore`
// permit to avoid ABBA deadlocks, so drop `conn` which holds a semaphore permit.

View File

@@ -153,6 +153,15 @@ pub enum StockMessage {
#[strum(props(fallback = "Forwarded"))]
Forwarded = 97,
#[strum(props(
fallback = "⚠️ Your provider's storage is about to exceed, already %1$s%% are used.\n\n\
You may not be able to receive message when the storage is 100%% used.\n\n\
👉 Please check if you can delete old data in the provider's webinterface \
and consider to enable \"Settings / Delete Old Messages\". \
You can check your current storage usage anytime at \"Settings / Connectivity\"."
))]
QuotaExceedingMsgBody = 98,
#[strum(props(fallback = "Multi Device Synchronization"))]
SyncMsgSubject = 101,
@@ -1091,6 +1100,13 @@ pub(crate) fn forwarded(context: &Context) -> String {
translated(context, StockMessage::Forwarded)
}
/// Stock string: `⚠️ Your provider's storage is about to exceed...`.
pub(crate) fn quota_exceeding(context: &Context, highest_usage: u64) -> String {
translated(context, StockMessage::QuotaExceedingMsgBody)
.replace1(&format!("{highest_usage}"))
.replace("%%", "%")
}
/// Stock string: `Incoming Messages`.
pub(crate) fn incoming_messages(context: &Context) -> String {
translated(context, StockMessage::IncomingMessages)

View File

@@ -102,6 +102,16 @@ async fn test_stock_system_msg_add_member_by_other_with_displayname() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quota_exceeding_stock_str() -> Result<()> {
let t = TestContext::new().await;
let str = quota_exceeding(&t, 81);
assert!(str.contains("81% "));
assert!(str.contains("100% "));
assert!(!str.contains("%%"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_device_chats() {
let t = TestContext::new_alice().await;

View File

@@ -275,17 +275,16 @@ impl TestContextManager {
let chat_id = join_securejoin(&joiner.ctx, qr).await.unwrap();
for _ in 0..2 {
loop {
let mut something_sent = false;
let rev_order = false;
if let Some(sent) = joiner.pop_sent_msg_ex(rev_order, Duration::ZERO).await {
if let Some(sent) = joiner.pop_sent_msg_opt(Duration::ZERO).await {
for inviter in inviters {
inviter.recv_msg_opt(&sent).await;
}
something_sent = true;
}
for inviter in inviters {
if let Some(sent) = inviter.pop_sent_msg_ex(rev_order, Duration::ZERO).await {
if let Some(sent) = inviter.pop_sent_msg_opt(Duration::ZERO).await {
joiner.recv_msg_opt(&sent).await;
something_sent = true;
}
@@ -624,35 +623,25 @@ impl TestContext {
}
pub async fn pop_sent_msg_opt(&self, timeout: Duration) -> Option<SentMessage<'_>> {
let rev_order = true;
self.pop_sent_msg_ex(rev_order, timeout).await
}
pub async fn pop_sent_msg_ex(
&self,
rev_order: bool,
timeout: Duration,
) -> Option<SentMessage<'_>> {
let start = Instant::now();
let mut query = "
SELECT id, msg_id, mime, recipients
FROM smtp
ORDER BY id"
.to_string();
if rev_order {
query += " DESC";
}
let (rowid, msg_id, payload, recipients) = loop {
let row = self
.ctx
.sql
.query_row_optional(&query, (), |row| {
let rowid: i64 = row.get(0)?;
let msg_id: MsgId = row.get(1)?;
let mime: String = row.get(2)?;
let recipients: String = row.get(3)?;
Ok((rowid, msg_id, mime, recipients))
})
.query_row_optional(
r#"
SELECT id, msg_id, mime, recipients
FROM smtp
ORDER BY id DESC"#,
(),
|row| {
let rowid: i64 = row.get(0)?;
let msg_id: MsgId = row.get(1)?;
let mime: String = row.get(2)?;
let recipients: String = row.get(3)?;
Ok((rowid, msg_id, mime, recipients))
},
)
.await
.expect("query_row_optional failed");
if let Some(row) = row {
@@ -793,7 +782,8 @@ ORDER BY id"
let chat_msgs = chat::get_chat_msgs(self, received.chat_id).await.unwrap();
assert!(
chat_msgs.contains(&ChatItem::Message { msg_id: msg.id }),
"received message is not shown in chat, maybe it's hidden"
"received message is not shown in chat, maybe it's hidden (you may have \
to call set_config(Config::ShowEmails, Some(\"2\")).await)"
);
msg
@@ -833,24 +823,17 @@ ORDER BY id"
assert_eq!(received.chat_id, DC_CHAT_ID_TRASH);
}
/// Gets the most recent message ID of a chat.
///
/// Panics on errors or if the most recent message is a marker.
pub async fn get_last_msg_id_in(&self, chat_id: ChatId) -> MsgId {
let msgs = chat::get_chat_msgs(&self.ctx, chat_id).await.unwrap();
if let ChatItem::Message { msg_id } = msgs.last().unwrap() {
*msg_id
} else {
panic!("Wrong item type");
}
}
/// Gets the most recent message of a chat.
///
/// Panics on errors or if the most recent message is a marker.
pub async fn get_last_msg_in(&self, chat_id: ChatId) -> Message {
let msg_id = self.get_last_msg_id_in(chat_id).await;
Message::load_from_db(&self.ctx, msg_id).await.unwrap()
let msgs = chat::get_chat_msgs(&self.ctx, chat_id).await.unwrap();
let msg_id = if let ChatItem::Message { msg_id } = msgs.last().unwrap() {
msg_id
} else {
panic!("Wrong item type");
};
Message::load_from_db(&self.ctx, *msg_id).await.unwrap()
}
/// Gets the most recent message over all chats.
@@ -1104,6 +1087,7 @@ ORDER BY id"
self,
chat_id,
MessageListOptions {
info_only: false,
add_daymarker: false,
},
)
@@ -1431,12 +1415,6 @@ pub fn fiona_keypair() -> SignedSecretKey {
#[derive(Debug)]
pub struct EventTracker(EventEmitter);
/// See [`super::EventTracker::get_matching_ex`].
pub struct ExpectedEvents<E: Fn(&EventType) -> bool, U: Fn(&EventType) -> bool> {
pub expected: E,
pub unexpected: U,
}
impl Deref for EventTracker {
type Target = EventEmitter;
@@ -1473,39 +1451,21 @@ impl EventTracker {
.expect("timeout waiting for event match")
}
/// Consumes all emitted events returning the first matching one if any.
/// Consumes emitted events returning the first matching one if any.
pub async fn get_matching_opt<F: Fn(&EventType) -> bool>(
&self,
ctx: &Context,
event_matcher: F,
) -> Option<EventType> {
self.get_matching_ex(
ctx,
ExpectedEvents {
expected: event_matcher,
unexpected: |_| false,
},
)
.await
}
/// Consumes all emitted events returning the first matching one if any. Panics on unexpected
/// events.
pub async fn get_matching_ex<E: Fn(&EventType) -> bool, U: Fn(&EventType) -> bool>(
&self,
ctx: &Context,
args: ExpectedEvents<E, U>,
) -> Option<EventType> {
ctx.emit_event(EventType::Test);
let mut found_event = None;
loop {
let event = self.recv().await.unwrap();
assert!(!(args.unexpected)(&event.typ));
if let EventType::Test = event.typ {
return found_event;
}
if (args.expected)(&event.typ) {
found_event.get_or_insert(event.typ);
if event_matcher(&event.typ) {
found_event = Some(event.typ);
}
}
}

View File

@@ -1,6 +1,4 @@
//! Tests about forwarding and saving Pre-Messages
use std::time::Duration;
use anyhow::Result;
use pretty_assertions::assert_eq;
@@ -10,7 +8,6 @@ use crate::chatlist::get_last_message_for_chat;
use crate::download::{DownloadState, PRE_MSG_ATTACHMENT_SIZE_THRESHOLD};
use crate::message::{Message, Viewtype};
use crate::test_utils::TestContextManager;
use crate::tests::pre_messages::util::send_large_file_message;
/// Test that forwarding Pre-Message should forward additional text to not be empty
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -89,43 +86,6 @@ async fn test_forwarding_pre_message_empty_text() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_receive_both() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_group_with_members("", &[bob]).await;
let (pre_message, post_message, alice_msg_id) =
send_large_file_message(alice, alice_chat_id, Viewtype::File, &vec![0u8; 200_000]).await?;
let msg = bob.recv_msg(&pre_message).await;
let _ = bob.recv_msg_trash(&post_message).await;
let msg = Message::load_from_db(bob, msg.id).await?;
assert_eq!(msg.download_state(), DownloadState::Done);
assert_eq!(msg.text, "test".to_owned());
forward_msgs(alice, &[alice_msg_id], alice_chat_id).await?;
let rev_order = false;
let msg = bob
.recv_msg(
&alice
.pop_sent_msg_ex(rev_order, Duration::ZERO)
.await
.unwrap(),
)
.await;
assert_eq!(msg.download_state(), DownloadState::Available);
assert_eq!(msg.is_forwarded(), true);
assert_eq!(msg.text, "test".to_owned());
let _ = bob.recv_msg_trash(&alice.pop_sent_msg().await).await;
let msg = Message::load_from_db(bob, msg.id).await?;
assert_eq!(msg.download_state(), DownloadState::Done);
assert_eq!(msg.is_forwarded(), true);
assert_eq!(msg.text, "test".to_owned());
Ok(())
}
/// Test that forwarding Pre-Message should forward additional text to not be empty
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_saving_pre_message_empty_text() -> Result<()> {

View File

@@ -5,9 +5,13 @@ use crate::download::DownloadState;
use crate::receive_imf::receive_imf_from_inbox;
use crate::test_utils::TestContext;
// The code for replacing partial download stubs is already removed, so check that nothing happens
// if after that a full message is passed to receive_imf. Users should ask the sender to send the
// message again.
// The code for downloading stub messages stays
// during the transition perios to pre-messages
// so people can still download their files shortly after they updated.
// After there are a few release with pre-message rolled out,
// we will remove the ability to download stub messages and replace the following test
// so it checks that it doesn't crash or that the messages are replaced by sth.
// like "download failed/expired, please ask sender to send it again"
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_download_stub_message() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -49,9 +53,9 @@ async fn test_download_stub_message() -> Result<()> {
)
.await?;
let msg = t.get_last_msg().await;
assert_eq!(msg.download_state(), DownloadState::Available);
assert_eq!(msg.download_state(), DownloadState::Done);
assert_eq!(msg.get_subject(), "foo");
assert!(msg.get_text().contains("[97.66 KiB message]"));
assert_eq!(msg.get_text(), "100k text...");
Ok(())
}

View File

@@ -6,7 +6,7 @@ use crate::chat::{self, Chat, add_contact_to_chat, remove_contact_from_chat, sen
use crate::config::Config;
use crate::constants::Chattype;
use crate::contact::{Contact, ContactId};
use crate::key::self_fingerprint;
use crate::key::{DcKey, load_self_public_key};
use crate::message::{Message, Viewtype};
use crate::mimefactory::MimeFactory;
use crate::mimeparser::SystemMessage;
@@ -152,7 +152,11 @@ async fn test_missing_key_reexecute_securejoin() -> Result<()> {
bob.sql
.execute(
"DELETE FROM public_keys WHERE fingerprint=?",
(&self_fingerprint(alice).await.unwrap(),),
(&load_self_public_key(alice)
.await
.unwrap()
.dc_fingerprint()
.hex(),),
)
.await?;
let chat = Chat::load_from_db(bob, chat_id).await?;

View File

@@ -44,7 +44,9 @@ async fn test_parse_receive_headers_integration() {
Message-ID: 2dfdbde7@example.org
Hop: From: localhost; By: hq5.merlinux.eu; Date: Sat, 14 Sep 2019 17:00:22 +0000
Hop: From: hq5.merlinux.eu; By: hq5.merlinux.eu; Date: Sat, 14 Sep 2019 17:00:25 +0000";
Hop: From: hq5.merlinux.eu; By: hq5.merlinux.eu; Date: Sat, 14 Sep 2019 17:00:25 +0000
DKIM Results: Passed=true";
check_parse_receive_headers_integration(raw, expected).await;
let raw = include_bytes!("../../test-data/message/encrypted_with_received_headers.eml");
@@ -54,7 +56,9 @@ Message-ID: Mr.adQpEwndXLH.LPDdlFVJ7wG@example.net
Hop: From: [127.0.0.1]; By: mail.example.org; Date: Mon, 27 Dec 2021 11:21:21 +0000
Hop: From: mout.example.org; By: hq5.example.org; Date: Mon, 27 Dec 2021 11:21:22 +0000
Hop: From: hq5.example.org; By: hq5.example.org; Date: Mon, 27 Dec 2021 11:21:22 +0000";
Hop: From: hq5.example.org; By: hq5.example.org; Date: Mon, 27 Dec 2021 11:21:22 +0000
DKIM Results: Passed=true";
check_parse_receive_headers_integration(raw, expected).await;
}

View File

@@ -791,18 +791,7 @@ pub(crate) async fn sync_transports(
context
.sql
.transaction(|transaction| {
let configured_addr = transaction.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)?;
for RemovedTransportData { addr, timestamp } in removed_transports {
if *addr == configured_addr {
continue;
}
modified |= transaction.execute(
"DELETE FROM transports
WHERE addr=? AND add_timestamp<=?",

View File

@@ -118,6 +118,8 @@ async fn test_posteo_alias() -> Result<()> {
t.set_config(Config::ConfiguredSendUser, Some(user)).await?;
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
@@ -205,6 +207,8 @@ async fn test_empty_server_list_legacy() -> Result<()> {
.await?; // Strict
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
.await?;
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
.await?; // Strict
t.set_config(Config::ConfiguredServerFlags, Some("0"))
.await?;
@@ -422,6 +426,13 @@ async fn check_addrs(
a.get_published_self_addrs().await.unwrap(),
published_self_addrs.clone(),
);
assert_eq(
a.get_secondary_self_addrs().await.unwrap(),
concat(&[
addresses.secondary_published,
addresses.secondary_unpublished,
]),
);
assert_eq(
a.get_published_secondary_self_addrs().await.unwrap(),
concat(&[addresses.secondary_published]),

View File

@@ -550,7 +550,7 @@ impl Context {
let send_now = !matches!(
instance.state,
MessageState::Undefined | MessageState::OutDraft
MessageState::Undefined | MessageState::OutPreparing | MessageState::OutDraft
);
status_update.uid = Some(create_id());

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas106.aol.mail.ne1.yahoo.com;
dkim=pass header.i=@buzon.uy header.s=2019;
spf=pass smtp.mailfrom=buzon.uy;
dmarc=pass(p=REJECT) header.from=buzon.uy;
From: <alice@buzon.uy>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas206.aol.mail.ne1.yahoo.com;
dkim=unknown;
spf=none smtp.mailfrom=delta.blinzeln.de;
dmarc=unknown header.from=delta.blinzeln.de;
From: <alice@delta.blinzeln.de>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas210.aol.mail.bf1.yahoo.com;
dkim=pass header.i=@disroot.org header.s=mail;
spf=pass smtp.mailfrom=disroot.org;
dmarc=pass(p=QUARANTINE) header.from=disroot.org;
From: <alice@disroot.org>
To: <alice@aol.com>

View File

@@ -0,0 +1,7 @@
Authentication-Results: atlas105.aol.mail.ne1.yahoo.com;
dkim=pass header.i=@fastmail.com header.s=fm2;
dkim=pass header.i=@messagingengine.com header.s=fm2;
spf=pass smtp.mailfrom=fastmail.com;
dmarc=pass(p=NONE,sp=NONE) header.from=fastmail.com;
From: <alice@fastmail.com>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas-baseline-production.v2-mail-prod1-gq1.omega.yahoo.com;
dkim=pass header.i=@gmail.com header.s=20210112;
spf=pass smtp.mailfrom=gmail.com;
dmarc=pass(p=NONE,sp=QUARANTINE) header.from=gmail.com;
From: <alice@gmail.com>
To: <alice@aol.com>

View File

@@ -0,0 +1,8 @@
Authentication-Results: atlas112.aol.mail.bf1.yahoo.com;
dkim=pass header.i=@hotmail.com header.s=selector1;
spf=pass smtp.mailfrom=hotmail.com;
dmarc=pass(p=NONE) header.from=hotmail.com;
ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none;
dkim=none; arc=none
From: <alice@hotmail.com>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas101.aol.mail.bf1.yahoo.com;
dkim=pass header.i=@icloud.com header.s=1a1hai;
spf=pass smtp.mailfrom=icloud.com;
dmarc=pass(p=QUARANTINE,sp=QUARANTINE) header.from=icloud.com;
From: <alice@icloud.com>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas-production.v2-mail-prod1-gq1.omega.yahoo.com;
dkim=pass header.i=@ik.me header.s=20200325;
spf=pass smtp.mailfrom=ik.me;
dmarc=pass(p=REJECT) header.from=ik.me;
From: <alice@ik.me>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas104.aol.mail.bf1.yahoo.com;
dkim=pass header.i=@mail.ru header.s=mail4;
spf=pass smtp.mailfrom=mail.ru;
dmarc=pass(p=REJECT) header.from=mail.ru;
From: <alice@mail.ru>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas211.aol.mail.bf1.yahoo.com;
dkim=pass header.i=@mailo.com header.s=mailo;
spf=pass smtp.mailfrom=mailo.com;
dmarc=pass(p=NONE) header.from=mailo.com;
From: <alice@mailo.com>
To: <alice@aol.com>

View File

@@ -0,0 +1,8 @@
Authentication-Results: atlas-production.v2-mail-prod1-gq1.omega.yahoo.com;
dkim=pass header.i=@outlook.com header.s=selector1;
spf=pass smtp.mailfrom=outlook.com;
dmarc=pass(p=NONE,sp=QUARANTINE) header.from=outlook.com;
ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none;
dkim=none; arc=none
From: <alice@outlook.com>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas-production.v2-mail-prod1-gq1.omega.yahoo.com;
dkim=pass header.i=@posteo.de header.s=2017;
spf=pass smtp.mailfrom=posteo.de;
dmarc=pass(p=NONE) header.from=posteo.de;
From: <alice@posteo.de>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas114.aol.mail.bf1.yahoo.com;
dkim=pass header.i=@yandex.ru header.s=mail;
spf=pass smtp.mailfrom=yandex.ru;
dmarc=pass(p=NONE) header.from=yandex.ru;
From: <alice@yandex.ru>
To: <alice@aol.com>

View File

@@ -0,0 +1,6 @@
Authentication-Results: atlas206.aol.mail.ne1.yahoo.com;
dkim=unknown;
spf=none smtp.mailfrom=delta.blinzeln.de;
dmarc=unknown header.from=delta.blinzeln.de;
From: forged-authres-added@example.com
Authentication-Results: aaa.com; dkim=pass header.i=@example.com

View File

@@ -0,0 +1,5 @@
Authentication-Results: mail.buzon.uy;
dkim=pass (2048-bit key; unprotected) header.d=aol.com header.i=@aol.com header.b="sjmqxpKe";
dkim-atps=neutral
From: <alice@aol.com>
To: <alice@buzon.uy>

Some files were not shown because too many files have changed in this diff Show More