Compare commits

..

22 Commits

Author SHA1 Message Date
link2xt
35d7b4e21c chore(release): prepare for 2.55.0 2026-07-20 18:47:28 +00:00
link2xt
03725c5f32 ci: update Node version to 24
https://docs.npmjs.com/trusted-publishers recommends node-version 24
and not updating npm separately, this change copies the recommended configuration.

Also update node version to 24 in non-release workflows, version 18 is EOL.
There is a version 26 already, but 24 is LTS.

Also disable cache for release pipelines.
2026-07-20 17:39:13 +00:00
link2xt
a0c96f4ce1 chore: bump version to 2.55.0-dev 2026-07-20 15:20:53 +00:00
link2xt
b432d08aa8 chore(release): prepare for 2.54.0 2026-07-20 14:57:57 +00:00
Jagoda Estera Ślązak
85db29bdfb fix: Apply reactions that arrived before the message at later time (#8415)
A new table `pending_reactions` is now used,
to store reactions that arrived before the referenced message.

When receiving a new message, pending_reactions is checked and any
reactions that arrived earlier are applied.

Fixes: #8367

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-07-20 13:50:05 +00:00
Hocuri
23c216c84b fix: Update last_rcvd_timestamp in migration (#8434)
Fix https://github.com/chatmail/core/issues/8429
2026-07-20 15:17:36 +02:00
Hocuri
d34dd40aa0 feat: Automatically remove oldest unpublished relay in order to make space when the user wants to add more; don't allow more than 5 relays overall (#8428)
Unpublished relays aren't shown in the list of relays anymore, and can't
be deleted manually. Therefore, we want to automatically delete them in
order to make space for more relays when the limit (5) is reached and
the user wants to add another relay.
2026-07-20 15:11:13 +02:00
link2xt
ebd1c20997 fix: do not log errors if full message is not available on any transport
Before this change "Err" case spams the logs with
"Failed to download message rfc724_mid=...: IMAP location for ... post-message is unknown."
logged by every transports when the message is not available on any transport.
2026-07-20 09:50:27 +00:00
Jagoda Estera Ślązak
0c40b52ec8 fix: Un-escape message footer marks in full messages (get_html) (#8427)
Un-escapes footer marks in long plain-text messages when retrieved with
`get_message_html`/`dc_get_msg_html`.

Additionally, makes escaping stricter,
only matching lines starting with `-- ` instead of `--`.

Fixes: #8269

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-07-20 09:44:14 +02:00
link2xt
aadd11a771 feat(imap): use CAPABILITY response code if LOGIN command returns it
This saves one round trip during connection establishment
if the server returns capabilities in the LOGIN command response already.
2026-07-17 21:36:45 +00:00
link2xt
17b32a17f5 chore: update async-imap to 0.11.3 2026-07-17 21:36:45 +00:00
link2xt
355ab8b3bc ci: update Rust to 1.97.1 2026-07-17 13:49:35 +00:00
iequidoo
e2b67d20fb refactor: Remove GROUP BY c.id from chatlist queries
Not clear why `GROUP BY` was needed -- a chat can't appear in the selected rows multiple times
because we do JOIN on the last message in the chat, there can't be multiple last messages.
2026-07-16 20:03:49 -03:00
iequidoo
0560c13ae5 perf: Add timestamp to msgs_index7 and speed up Chatlist::try_load() (#7848)
This speeds up chatlisting by 3--4 times on my biggest profile, so the difference is visually
noticeable, particularly after dropping disk caches. Before, `msgs_index2` was used which has less
ordering and requires a full scan of `msgs` table.
2026-07-16 20:03:49 -03:00
link2xt
1a24bf612a refactor: label the loop iterating over the candidates
The loop is large, so having an explicit label
makes it easier to understand what the "continue" statement does.
2026-07-16 09:49:41 +00:00
link2xt
492741ac60 fix(imap): do not bubble up errors in IMAP candidate loop
Network errors such as timeouts should not break out of the loop
by bubbling up errors. There may be more candidates to try,
breaking out of the loop skips trying them.
2026-07-16 09:49:41 +00:00
link2xt
d940f96a5c feat: limit the number of published relays instead of all relays 2026-07-15 17:24:58 +00:00
link2xt
e5c920f1a1 api(deltachat-rpc-client): add Account.set_transport_unpublished() 2026-07-15 17:24:58 +00:00
Jagoda Estera Ślązak
5d0e5efc94 feat: Remove hidden relays automatically (#8402)
Automatically remove relays that are hidden (`is_published=0`) and
haven't been used to receive new messages for over 90 days.

Closes: #8384

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-07-15 12:31:11 +00:00
dependabot[bot]
94fc4f7550 chore(deps): bump taiki-e/install-action from 2.82.6 to 2.82.10
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.82.6 to 2.82.10.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](9bcaee1dca...50414676f9)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.82.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 13:13:43 +00:00
link2xt
ce2d237ffd refactor: remove unnecessary reference in format string 2026-07-14 11:14:36 +00:00
link2xt
f2d11305bd chore: update yanked spin@0.9.8 and spin@0.10.0
Upstream issue with the reason for yanking the crate is <https://codeberg.org/zesterer/spin/issues/192>
2026-07-14 11:14:10 +00:00
44 changed files with 1429 additions and 582 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.97.0
RUST_VERSION: 1.97.1
# Minimum Supported Rust Version
MSRV: 1.89.0
@@ -146,7 +146,7 @@ jobs:
cache-bin: false
- name: Install nextest
uses: taiki-e/install-action@9bcaee1dcae34154180f412e2fa69355a7cda9f6
uses: taiki-e/install-action@50414676f9f5d50a65992c6dd2ed02641263226c
with:
tool: nextest

View File

@@ -513,15 +513,12 @@ jobs:
deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing.
# Check <https://docs.npmjs.com/trusted-publishers> for the version requirements.
- uses: actions/setup-node@v6
with:
node-version: 20
node-version: 24
registry-url: "https://registry.npmjs.org"
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
package-manager-cache: false # never use caching in release builds
- name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server`
if: github.event_name == 'release'

View File

@@ -22,15 +22,13 @@ jobs:
show-progress: false
persist-credentials: false
# Configure Node.js for publishing.
# Check <https://docs.npmjs.com/trusted-publishers> for the version requirements.
- uses: actions/setup-node@v6
with:
node-version: 20
node-version: 24
registry-url: "https://registry.npmjs.org"
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
package-manager-cache: false # never use caching in release builds
- name: Install dependencies without running scripts
working-directory: deltachat-jsonrpc/typescript

View File

@@ -20,10 +20,10 @@ jobs:
with:
show-progress: false
persist-credentials: false
- name: Use Node.js 18.x
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 18.x
node-version: 24
- name: Add Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:

View File

@@ -92,7 +92,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: '18'
node-version: 24
- name: npm install
run: npm install
- name: npm run build

View File

@@ -1,5 +1,111 @@
# Changelog
## [2.55.0] - 2026-07-20
Minor release to fix CI because releasing 2.54.0 failed.
### CI
- Update Node version to 24.
## [2.54.0] - 2026-07-20
### API-Changes
- [**breaking**] Deprecate `is_chatmail`.
- UIs should not behave differently for chatmail relays than for classical email servers; most usages of `is_chatmail` can be replaced by `force_encryption`.
- [**breaking**] `delete_transport()` must not be used by UIs anymore. Instead, `set_transport_unpublished()` must be called when a user clicks on "Remove".
- [**breaking**] `list_transports()` doesn't return unpublished relays anymore.
- UIs should use `list_transports()` rather than `list_transports_ex()`, because unpublished transports count as removed from the user point of view, and should not be shown in the relay list anymore.
- deltachat-rpc-client: add `Account.set_transport_unpublished()`.
- Add `MsgReadCountChanged` event.
### Features / Changes
- Implement support for populating and maintaining a list of default relays ([#8341](https://github.com/chatmail/core/pull/8341)).
- Remove hidden relays automatically ([#8402](https://github.com/chatmail/core/pull/8402)).
- Automatically remove oldest unpublished relay in order to make space when the user wants to add more; don't allow more than 5 relays overall ([#8428](https://github.com/chatmail/core/pull/8428)).
- Add silent group changes messages as InNoticed, not InSeen.
- Remove `?emailaddress` argument from autoconfig URL that is not using a dedicated domain.
- Remove `imap::Session::sync_seen_flags()` ([#7742](https://github.com/chatmail/core/pull/7742)).
- Use CAPABILITY response code if IMAP LOGIN command returns it.
- Increase max idle timeout for iroh backup receiver to 60 seconds.
### Fixes
- Request MDNs for resent channel messages.
- Make pre-messages w/o text want MDNs ([#8004](https://github.com/chatmail/core/pull/8004)).
- Make truncated edited messages have HTML for receivers ([#8249](https://github.com/chatmail/core/pull/8249)).
- Un-escape message footer marks in full messages (`get_html`) ([#8427](https://github.com/chatmail/core/pull/8427)).
- Hide synced chat if we only know its visibility ([#8343](https://github.com/chatmail/core/pull/8343)).
- Tombstone MDN before sending it ([#8252](https://github.com/chatmail/core/pull/8252)).
- Recreate `imap_markseen` with `PRIMARY KEY` constraint.
- Rerun the full securejoin protocol if the address was outdated ([#8358](https://github.com/chatmail/core/pull/8358)).
- Return early from `receive_imf` to not tombstone Iroh-Node-Addr message if webxdc instance isn't found ([#8372](https://github.com/chatmail/core/pull/8372)).
- Replace `last_added_location_id` with `last_added_location_timestamp`.
- Do not put locations into pre-messages.
- RUSTSEC-2026-0204 ([#8403](https://github.com/chatmail/core/pull/8403)).
- Ensure public key signatures are not in the past compared to the public key.
- Do not bubble up errors in IMAP candidate loop.
- Do not log errors if full message is not available on any transport.
- Apply reactions that arrived before the message at later time ([#8415](https://github.com/chatmail/core/pull/8415)).
### Performance
- Add timestamp to `msgs_index7` and speed up `Chatlist::try_load()` ([#7848](https://github.com/chatmail/core/pull/7848)).
### CI
- Update Rust to 1.97.1.
- rrsync prepends the restricted upload path, we need to leave it out ([#8405](https://github.com/chatmail/core/pull/8405)).
### Documentation
- Update STYLE.md: macros should be used only when necessary ([#8410](https://github.com/chatmail/core/pull/8410)).
- `create_group_chat_unencrypted()` may lead to chat split on the first device.
### Refactor
- Deprecate unused `SkipAutocrypt` param.
- Remove commented out `RenderedEmail.envelope`.
- Remove the ability to send messages with non-standard header protection.
- Make `crate::pgp::symm_encrypt_message` non-async.
- Move `ensure_secret_key_exists` into key.rs.
- Improve comment ([#8366](https://github.com/chatmail/core/pull/8366)).
- Remove `set_modseq()` function.
- Remove unnecessary reference in format string.
- Label the loop iterating over the candidates.
- Remove `GROUP BY c.id` from chatlist queries.
### Tests
- securejoin: Check that "vc-{,request-}pubkey" messages don't contain displayname.
### Miscellaneous Tasks
- bump version to 2.54.0-dev.
- deps: bump taiki-e/install-action from 2.81.1 to 2.81.8.
- deps: bump taiki-e/install-action from 2.81.8 to 2.81.11.
- update rPGP from 0.19.0 to 0.20.0.
- update astral-tokio-tar from 0.6.2 to 0.6.3.
- deps: bump anyhow to 1.0.103.
- deps: bump actions/checkout from 6 to 7.
- cargo: bump syn from 2.0.117 to 2.0.118.
- cargo: bump quote from 1.0.45 to 1.0.46.
- cargo: bump bytes from 1.11.1 to 1.12.0.
- cargo: bump regex from 1.12.3 to 1.12.4.
- cargo: bump log from 0.4.31 to 0.4.33.
- cargo: bump hyper from 1.9.0 to 1.10.1.
- deps: bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7.
- cargo: bump chrono from 0.4.44 to 0.4.45.
- update quick-xml to 0.41.0.
- cargo: bump brotli from 8.0.2 to 8.0.4.
- cargo: bump smallvec from 1.15.1 to 1.15.2.
- deps: bump taiki-e/install-action from 2.81.11 to 2.82.6.
- update yanked spin@0.9.8 and spin@0.10.0.
- deps: bump taiki-e/install-action from 2.82.6 to 2.82.10.
- update async-imap to 0.11.3.
## [2.53.0] - 2026-06-15
### Features / Changes
@@ -8374,3 +8480,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.51.0]: https://github.com/chatmail/core/compare/v2.50.0..v2.51.0
[2.52.0]: https://github.com/chatmail/core/compare/v2.51.0..v2.52.0
[2.53.0]: https://github.com/chatmail/core/compare/v2.52.0..v2.53.0
[2.54.0]: https://github.com/chatmail/core/compare/v2.53.0..v2.54.0
[2.55.0]: https://github.com/chatmail/core/compare/v2.54.0..v2.55.0

28
Cargo.lock generated
View File

@@ -271,9 +271,9 @@ dependencies = [
[[package]]
name = "async-imap"
version = "0.11.2"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66"
checksum = "9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570"
dependencies = [
"async-channel 2.5.0",
"async-compression",
@@ -1350,7 +1350,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.54.0-dev"
version = "2.55.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1459,7 +1459,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.54.0-dev"
version = "2.55.0"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1480,7 +1480,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.54.0-dev"
version = "2.55.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1496,7 +1496,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.54.0-dev"
version = "2.55.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1525,7 +1525,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.54.0-dev"
version = "2.55.0"
dependencies = [
"anyhow",
"deltachat",
@@ -2179,7 +2179,7 @@ dependencies = [
"diatomic-waker",
"futures-core",
"pin-project-lite",
"spin 0.9.8",
"spin 0.9.9",
]
[[package]]
@@ -3342,7 +3342,7 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin 0.9.8",
"spin 0.9.9",
]
[[package]]
@@ -5708,7 +5708,7 @@ dependencies = [
"serde_urlencoded",
"shadowsocks-crypto",
"socket2 0.5.9",
"spin 0.10.0",
"spin 0.10.1",
"thiserror 2.0.18",
"tokio",
"tokio-tfo",
@@ -5895,15 +5895,15 @@ dependencies = [
[[package]]
name = "spin"
version = "0.9.8"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spin"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
dependencies = [
"lock_api",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.54.0-dev"
version = "2.55.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
@@ -44,7 +44,7 @@ ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-imap = { version = "0.11.3", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }

View File

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

View File

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

View File

@@ -590,7 +590,7 @@ impl CommandApi {
/// Immediately deletes a transport, potentially causing messages not to arrive.
/// This must ONLY be used by the automated tests.
/// UI implementations must use `set_transport_unpublished()` instead.
/// UI implementations must use [`Self::set_transport_unpublished`] instead.
async fn delete_transport(&self, account_id: u32, addr: String) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.delete_transport(&addr).await
@@ -605,6 +605,14 @@ impl CommandApi {
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know our new transport addresses yet.
///
/// When more transports are added by [`Self::add_or_update_transport()`] or [`Self::add_transport_from_qr`],
/// the least recently needed unpublished transport is automatically removed
/// if this is necessary in order to stay below the maximum number of allowed relays.
/// Also, unpublished transports that are not used to receive any new messages for a time defined by
/// [`UNPUBLISHED_TRANSPORT_KEEP_TIME`] are automatically removed.
///
/// [`UNPUBLISHED_TRANSPORT_KEEP_TIME`]: deltachat::sql::UNPUBLISHED_TRANSPORT_KEEP_TIME
async fn set_transport_unpublished(
&self,
account_id: u32,

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.54.0-dev"
"version": "2.55.0"
}

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.54.0-dev"
version = "2.55.0"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [

View File

@@ -133,6 +133,10 @@ class Account:
"""Delete a transport."""
self._rpc.delete_transport(self.id, addr)
def set_transport_unpublished(self, addr: str, unpublished: bool = True):
"""Unpublish the transport."""
self._rpc.set_transport_unpublished(self.id, addr, unpublished)
@futuremethod
def list_transports(self):
"""Return the list of all email accounts that are used as a transport in the current profile."""

View File

@@ -237,10 +237,21 @@ def test_transport_limit(acfactory) -> None:
account.add_transport_from_qr(qr)
second_addr = account.list_transports()[1]["addr"]
account.delete_transport(second_addr)
third_addr = account.list_transports()[2]["addr"]
# test that adding a transport after deleting one works again
# test that adding a transport after unpublishing one works again
account.set_transport_unpublished(second_addr)
account.add_transport_from_qr(qr)
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
# UIs are not expected to delete transports directly,
# but we still test that adding a transport
# after deleting one instead of unpublishing works.
account.delete_transport(third_addr)
account.add_transport_from_qr(qr)
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_message_info_imap_urls(acfactory) -> None:

View File

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

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.54.0-dev"
"version": "2.55.0"
}

444
flake.nix
View File

@@ -29,7 +29,11 @@
rustc = fenixToolchain;
};
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
version = manifest.version;
androidSdk = android.sdk.${system} (sdkPkgs:
builtins.attrValues {
inherit (sdkPkgs) ndk-27-2-12479018 cmdline-tools-latest;
});
androidNdkRoot = "${androidSdk}/share/android-sdk/ndk/27.2.12479018";
rustSrc = nix-filter.lib {
root = ./.;
@@ -79,7 +83,7 @@
naersk'.buildPackage {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
inherit version;
version = manifest.version;
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
@@ -87,22 +91,221 @@
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
};
mkWin64RustPackage = packageName:
let
pkgsWin64 = pkgs.pkgsCross.mingwW64;
rustTarget = pkgsWin64.stdenv.hostPlatform.rust.rustcTarget;
toolchainWin = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naerskWin = pkgs.callPackage naersk {
cargo = toolchainWin;
rustc = toolchainWin;
};
in
naerskWin.buildPackage rec {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
version = manifest.version;
strictDeps = true;
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
];
depsBuildBuild = [
pkgsWin64.stdenv.cc
];
buildInputs = [
pkgsWin64.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
mkWin64RustPackage = pkgs.callPackage ./nix/win64-package.nix {
inherit naersk system fenixPkgs version;
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
CFLAGS_x86_64_pc_windows_gnu = "-I${pkgsWin64.windows.pthreads}/include";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
"-L"
"native=${pkgsWin64.windows.pthreads}/lib"
];
CC = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
LD = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
};
mkWin32RustPackage = packageName:
let
pkgsWin32 = pkgs.pkgsCross.mingw32;
rustTarget = pkgsWin32.stdenv.hostPlatform.rust.rustcTarget;
toolchainWin = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naerskWin = pkgs.callPackage naersk {
cargo = toolchainWin;
rustc = toolchainWin;
};
# Get rid of MCF Gthread library.
# See <https://github.com/NixOS/nixpkgs/issues/156343>
# and <https://discourse.nixos.org/t/statically-linked-mingw-binaries/38395>
# for details.
#
# Use DWARF-2 instead of SJLJ for exception handling.
winCC = pkgsWin32.buildPackages.wrapCC (
(pkgsWin32.buildPackages.gcc-unwrapped.override
({
threadsCross = {
model = "win32";
package = null;
};
})).overrideAttrs (oldAttr: {
configureFlags = oldAttr.configureFlags ++ [
"--disable-sjlj-exceptions"
"--with-dwarf2"
];
})
);
in
naerskWin.buildPackage rec {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
version = manifest.version;
strictDeps = true;
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
pkgs.nasm # aws-lc-sys requires it
];
depsBuildBuild = [
winCC
];
buildInputs = [
pkgsWin32.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${winCC}/bin/${winCC.targetPrefix}cc";
CFLAGS_i686_pc_windows_gnu = "-I${pkgsWin32.windows.pthreads}/include";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
"-L"
"native=${pkgsWin32.windows.pthreads}/lib"
];
CC = "${winCC}/bin/${winCC.targetPrefix}cc";
LD = "${winCC}/bin/${winCC.targetPrefix}cc";
};
mkCrossRustPackage = arch: packageName:
let
crossTarget = arch2targets."${arch}";
pkgsCross = import nixpkgs {
system = system;
crossSystem.config = crossTarget;
};
rustTarget = pkgsCross.stdenv.hostPlatform.rust.rustcTarget;
toolchain = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naersk-lib = pkgs.callPackage naersk {
cargo = toolchain;
rustc = toolchain;
};
in
naersk-lib.buildPackage rec {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
version = manifest.version;
strictDeps = true;
src = rustSrc;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS = "-Clink-args=-L${pkgsCross.libiconv}/lib";
CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS = "-Clink-args=-L${pkgsCross.libiconv}/lib";
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${pkgsCross.stdenv.cc}/bin/${pkgsCross.stdenv.cc.targetPrefix}cc";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
];
CC = "${pkgsCross.stdenv.cc}/bin/${pkgsCross.stdenv.cc.targetPrefix}cc";
LD = "${pkgsCross.stdenv.cc}/bin/${pkgsCross.stdenv.cc.targetPrefix}cc";
};
androidAttrs = {
armeabi-v7a = {
cc = "armv7a-linux-androideabi21-clang";
rustTarget = "armv7-linux-androideabi";
};
arm64-v8a = {
cc = "aarch64-linux-android21-clang";
rustTarget = "aarch64-linux-android";
};
x86 = {
cc = "i686-linux-android21-clang";
rustTarget = "i686-linux-android";
};
x86_64 = {
cc = "x86_64-linux-android21-clang";
rustTarget = "x86_64-linux-android";
};
};
mkWin32RustPackage = pkgs.callPackage ./nix/win32-package.nix {
inherit naersk system fenixPkgs version;
};
mkAndroidRustPackage = arch: packageName:
let
rustTarget = androidAttrs.${arch}.rustTarget;
toolchain = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naersk-lib = pkgs.callPackage naersk {
cargo = toolchain;
rustc = toolchain;
};
targetToolchain = "${androidNdkRoot}/toolchains/llvm/prebuilt/linux-x86_64";
targetCcName = androidAttrs.${arch}.cc;
targetCc = "${targetToolchain}/bin/${targetCcName}";
in
naersk-lib.buildPackage rec {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
version = manifest.version;
strictDeps = true;
src = rustSrc;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
mkCrossRustPackage = pkgs.callPackage ./nix/cross-rust-package.nix {
inherit nixpkgs arch2targets naersk fenixPkgs system rustSrc version;
};
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${targetCc}";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
];
mkAndroidRustPackage = pkgs.callPackage ./nix/android-package.nix {
inherit naersk fenixPkgs system rustSrc android version;
};
CC = "${targetCc}";
LD = "${targetCc}";
};
mkAndroidPackages = arch:
let
@@ -112,7 +315,32 @@
"deltachat-rpc-server-${arch}-android" = rpc-server;
"deltachat-repl-${arch}-android" = mkAndroidRustPackage arch "deltachat-repl";
"deltachat-rpc-server-${arch}-android-wheel" =
mkWheel { inherit rpc-server; arch = "${arch}-android"; };
pkgs.stdenv.mkDerivation {
pname = "deltachat-rpc-server-${arch}-android-wheel";
version = manifest.version;
src = nix-filter.lib {
root = ./.;
include = [
"scripts/wheel-rpc-server.py"
"deltachat-rpc-server/README.md"
"LICENSE"
"Cargo.toml"
];
};
nativeBuildInputs = [
pkgs.python3
pkgs.python3Packages.wheel
];
buildInputs = [
rpc-server
];
buildPhase = ''
mkdir tmp
cp ${rpc-server}/bin/deltachat-rpc-server tmp/deltachat-rpc-server
python3 scripts/wheel-rpc-server.py ${arch}-android tmp/deltachat-rpc-server
'';
installPhase = ''mkdir -p $out; cp -av deltachat_rpc_server-*.whl $out'';
};
};
mkRustPackages = arch:
@@ -122,10 +350,34 @@
{
"deltachat-repl-${arch}" = mkCrossRustPackage arch "deltachat-repl";
"deltachat-rpc-server-${arch}" = rpc-server;
"deltachat-rpc-server-${arch}-wheel" = mkWheel { inherit rpc-server; arch = "${arch}"; };
"deltachat-rpc-server-${arch}-wheel" =
pkgs.stdenv.mkDerivation {
pname = "deltachat-rpc-server-${arch}-wheel";
version = manifest.version;
src = nix-filter.lib {
root = ./.;
include = [
"scripts/wheel-rpc-server.py"
"deltachat-rpc-server/README.md"
"LICENSE"
"Cargo.toml"
];
};
nativeBuildInputs = [
pkgs.python3
pkgs.python3Packages.wheel
];
buildInputs = [
rpc-server
];
buildPhase = ''
mkdir tmp
cp ${rpc-server}/bin/deltachat-rpc-server tmp/deltachat-rpc-server
python3 scripts/wheel-rpc-server.py ${arch} tmp/deltachat-rpc-server
'';
installPhase = ''mkdir -p $out; cp -av deltachat_rpc_server-*.whl $out'';
};
};
mkWheel = pkgs.callPackage ./nix/wheel.nix { inherit nix-filter version; root = ./.; };
in
{
formatter = pkgs.nixpkgs-fmt;
@@ -149,29 +401,116 @@
deltachat-repl-win64 = mkWin64RustPackage "deltachat-repl";
deltachat-rpc-server-win64 = mkWin64RustPackage "deltachat-rpc-server";
deltachat-rpc-server-win64-wheel =
mkWheel { rpc-server = deltachat-rpc-server-win64; arch = "win64"; binaryName = "deltachat-rpc-server.exe"; };
pkgs.stdenv.mkDerivation {
pname = "deltachat-rpc-server-win64-wheel";
version = manifest.version;
src = nix-filter.lib {
root = ./.;
include = [
"scripts/wheel-rpc-server.py"
"deltachat-rpc-server/README.md"
"LICENSE"
"Cargo.toml"
];
};
nativeBuildInputs = [
pkgs.python3
pkgs.python3Packages.wheel
];
buildInputs = [
deltachat-rpc-server-win64
];
buildPhase = ''
mkdir tmp
cp ${deltachat-rpc-server-win64}/bin/deltachat-rpc-server.exe tmp/deltachat-rpc-server.exe
python3 scripts/wheel-rpc-server.py win64 tmp/deltachat-rpc-server.exe
'';
installPhase = ''mkdir -p $out; cp -av deltachat_rpc_server-*.whl $out'';
};
deltachat-repl-win32 = mkWin32RustPackage "deltachat-repl";
deltachat-rpc-server-win32 = mkWin32RustPackage "deltachat-rpc-server";
deltachat-rpc-server-win32-wheel =
mkWheel
{ rpc-server = deltachat-rpc-server-win32; arch = "win32"; binaryName = "deltachat-rpc-server.exe"; };
pkgs.stdenv.mkDerivation {
pname = "deltachat-rpc-server-win32-wheel";
version = manifest.version;
src = nix-filter.lib {
root = ./.;
include = [
"scripts/wheel-rpc-server.py"
"deltachat-rpc-server/README.md"
"LICENSE"
"Cargo.toml"
];
};
nativeBuildInputs = [
pkgs.python3
pkgs.python3Packages.wheel
];
buildInputs = [
deltachat-rpc-server-win32
];
buildPhase = ''
mkdir tmp
cp ${deltachat-rpc-server-win32}/bin/deltachat-rpc-server.exe tmp/deltachat-rpc-server.exe
python3 scripts/wheel-rpc-server.py win32 tmp/deltachat-rpc-server.exe
'';
installPhase = ''mkdir -p $out; cp -av deltachat_rpc_server-*.whl $out'';
};
# Run `nix build .#docs` to get C docs generated in `./result/`.
docs = pkgs.callPackage ./nix/c-docs.nix { inherit version; };
docs =
pkgs.stdenv.mkDerivation {
pname = "docs";
version = manifest.version;
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [ pkgs.doxygen ];
buildPhase = ''scripts/run-doxygen.sh'';
installPhase = ''mkdir -p $out; cp -av deltachat-ffi/html deltachat-ffi/xml $out'';
};
libdeltachat = pkgs.callPackage ./nix/libdeltachat.nix {
inherit fenixToolchain rustSrc cargoLock fenixPkgs version;
};
libdeltachat =
let
rustPlatform = (pkgs.makeRustPlatform {
cargo = fenixToolchain;
rustc = fenixToolchain;
});
in
pkgs.stdenv.mkDerivation {
pname = "libdeltachat";
version = manifest.version;
src = rustSrc;
cargoDeps = pkgs.rustPlatform.importCargoLock cargoLock;
deltachat-rpc-client = pkgs.callPackage ./nix/deltachat-rpc-client.nix {
inherit version;
};
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
pkgs.cmake
rustPlatform.cargoSetupHook
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
];
postInstall = ''
substituteInPlace $out/include/deltachat.h \
--replace __FILE__ '"${placeholder "out"}/include/deltachat.h"'
'';
};
deltachat-rpc-client =
pkgs.python3Packages.buildPythonPackage {
pname = "deltachat-rpc-client";
version = manifest.version;
src = pkgs.lib.cleanSource ./deltachat-rpc-client;
format = "pyproject";
propagatedBuildInputs = [
pkgs.python3Packages.setuptools
pkgs.python3Packages.imap-tools
];
};
deltachat-python =
pkgs.python3Packages.buildPythonPackage {
pname = "deltachat-python";
inherit version;
version = manifest.version;
src = pkgs.lib.cleanSource ./python;
format = "pyproject";
buildInputs = [
@@ -189,12 +528,51 @@
pkgs.python3Packages.requests
];
};
python-docs = pkgs.callPackage ./nix/python-docs.nix {
inherit deltachat-python deltachat-rpc-client version;
};
python-docs =
pkgs.stdenv.mkDerivation {
pname = "docs";
version = manifest.version;
src = pkgs.lib.cleanSource ./.;
buildInputs = [
deltachat-python
deltachat-rpc-client
pkgs.python3Packages.breathe
pkgs.python3Packages.sphinx-rtd-theme
];
nativeBuildInputs = [ pkgs.sphinx ];
buildPhase = ''sphinx-build -b html -a python/doc/ dist/html'';
installPhase = ''mkdir -p $out; cp -av dist/html $out'';
};
};
devShells.default = import ./nix/shell.nix { inherit nixpkgs fenix system; };
devShells.default =
let
pkgs = import nixpkgs {
system = system;
overlays = [ fenix.overlays.default ];
};
in
pkgs.mkShell {
buildInputs = with pkgs; [
(fenix.packages.${system}.complete.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
])
cargo-deny
rust-analyzer-nightly
cargo-nextest
perl # needed to build vendored OpenSSL
git-cliff
(python3.withPackages (pypkgs: with pypkgs; [
tox
]))
nodejs
];
};
}
);
}

View File

@@ -1,63 +0,0 @@
{ pkgs, naersk, fenixPkgs, system, version, rustSrc, android }:
arch: packageName:
let
androidSdk = android.sdk.${system} (sdkPkgs:
builtins.attrValues {
inherit (sdkPkgs) ndk-27-2-12479018 cmdline-tools-latest;
});
androidNdkRoot = "${androidSdk}/share/android-sdk/ndk/27.2.12479018";
androidAttrs = {
armeabi-v7a = {
cc = "armv7a-linux-androideabi21-clang";
rustTarget = "armv7-linux-androideabi";
};
arm64-v8a = {
cc = "aarch64-linux-android21-clang";
rustTarget = "aarch64-linux-android";
};
x86 = {
cc = "i686-linux-android21-clang";
rustTarget = "i686-linux-android";
};
x86_64 = {
cc = "x86_64-linux-android21-clang";
rustTarget = "x86_64-linux-android";
};
};
rustTarget = androidAttrs.${arch}.rustTarget;
toolchain = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naersk-lib = pkgs.callPackage naersk {
cargo = toolchain;
rustc = toolchain;
};
targetToolchain = "${androidNdkRoot}/toolchains/llvm/prebuilt/linux-x86_64";
targetCcName = androidAttrs.${arch}.cc;
targetCc = "${targetToolchain}/bin/${targetCcName}";
in
naersk-lib.buildPackage {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
inherit version;
strictDeps = true;
src = rustSrc;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${targetCc}";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${targetCc}"
];
CC = "${targetCc}";
LD = "${targetCc}";
}

View File

@@ -1,9 +0,0 @@
{ pkgs, version }:
pkgs.stdenv.mkDerivation {
pname = "docs";
inherit version;
src = pkgs.lib.cleanSource ../.;
nativeBuildInputs = [ pkgs.doxygen ];
buildPhase = ''scripts/run-doxygen.sh'';
installPhase = ''mkdir -p $out; cp -av deltachat-ffi/html deltachat-ffi/xml $out'';
}

View File

@@ -1,45 +0,0 @@
{ pkgs, nixpkgs, arch2targets, naersk, fenixPkgs, system, rustSrc, version }:
arch: packageName:
let
crossTarget = arch2targets."${arch}";
pkgsCross = import nixpkgs {
system = system;
crossSystem.config = crossTarget;
};
rustTarget = pkgsCross.stdenv.hostPlatform.rust.rustcTarget;
toolchain = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naersk-lib = pkgs.callPackage naersk {
cargo = toolchain;
rustc = toolchain;
};
targetCc = "${pkgsCross.stdenv.cc}/bin/${pkgsCross.stdenv.cc.targetPrefix}cc";
in
naersk-lib.buildPackage {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
inherit version;
strictDeps = true;
src = rustSrc;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS = "-Clink-args=-L${pkgsCross.libiconv}/lib";
CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS = "-Clink-args=-L${pkgsCross.libiconv}/lib";
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${targetCc}";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${targetCc}"
];
CC = "${targetCc}";
LD = "${targetCc}";
}

View File

@@ -1,11 +0,0 @@
{ pkgs, version }:
pkgs.python3Packages.buildPythonPackage {
pname = "deltachat-rpc-client";
inherit version;
src = pkgs.lib.cleanSource ../deltachat-rpc-client;
format = "pyproject";
propagatedBuildInputs = [
pkgs.python3Packages.setuptools
pkgs.python3Packages.imap-tools
];
}

View File

@@ -1,26 +0,0 @@
{ pkgs, fenixToolchain, rustSrc, cargoLock, fenixPkgs, version }:
let
rustPlatform = (pkgs.makeRustPlatform {
cargo = fenixToolchain;
rustc = fenixToolchain;
});
in
pkgs.stdenv.mkDerivation {
pname = "libdeltachat";
inherit version;
src = rustSrc;
cargoDeps = pkgs.rustPlatform.importCargoLock cargoLock;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
pkgs.cmake
rustPlatform.cargoSetupHook
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
];
postInstall = ''
substituteInPlace $out/include/deltachat.h \
--replace __FILE__ '"${placeholder "out"}/include/deltachat.h"'
'';
}

View File

@@ -1,15 +0,0 @@
{ pkgs, version, deltachat-python, deltachat-rpc-client }:
pkgs.stdenv.mkDerivation {
pname = "docs";
inherit version;
src = pkgs.lib.cleanSource ../.;
buildInputs = [
deltachat-python
deltachat-rpc-client
pkgs.python3Packages.breathe
pkgs.python3Packages.sphinx-rtd-theme
];
nativeBuildInputs = [ pkgs.sphinx ];
buildPhase = ''sphinx-build -b html -a python/doc/ dist/html'';
installPhase = ''mkdir -p $out; cp -av dist/html $out'';
}

View File

@@ -1,27 +0,0 @@
{ nixpkgs, fenix, system }:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ fenix.overlays.default ];
};
in
pkgs.mkShell {
buildInputs = with pkgs; [
(fenix.packages.${system}.complete.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
])
cargo-deny
rust-analyzer-nightly
cargo-nextest
perl # needed to build vendored OpenSSL
git-cliff
(python3.withPackages (pypkgs: [
pypkgs.tox
]))
nodejs
];
}

View File

@@ -1,28 +0,0 @@
{ pkgs, nix-filter, version, root }:
{ rpc-server, arch, binaryName ? "deltachat-rpc-server" }:
pkgs.stdenv.mkDerivation {
pname = "deltachat-rpc-server-${arch}-wheel";
inherit version;
src = nix-filter.lib {
inherit root;
include = [
"scripts/wheel-rpc-server.py"
"deltachat-rpc-server/README.md"
"LICENSE"
"Cargo.toml"
];
};
nativeBuildInputs = [
pkgs.python3
pkgs.python3Packages.wheel
];
buildInputs = [
rpc-server
];
buildPhase = ''
mkdir tmp
cp ${rpc-server}/bin/${binaryName} tmp/${binaryName}
python3 scripts/wheel-rpc-server.py ${arch} tmp/${binaryName}
'';
installPhase = ''mkdir -p $out; cp -av deltachat_rpc_server-*.whl $out'';
}

View File

@@ -1,69 +0,0 @@
{ pkgs, naersk, fenixPkgs, system, version }:
packageName:
let
pkgsWin32 = pkgs.pkgsCross.mingw32;
rustTarget = pkgsWin32.stdenv.hostPlatform.rust.rustcTarget;
toolchainWin = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naerskWin = pkgs.callPackage naersk {
cargo = toolchainWin;
rustc = toolchainWin;
};
# Get rid of MCF Gthread library.
# See <https://github.com/NixOS/nixpkgs/issues/156343>
# and <https://discourse.nixos.org/t/statically-linked-mingw-binaries/38395>
# for details.
#
# Use DWARF-2 instead of SJLJ for exception handling.
winCC = pkgsWin32.buildPackages.wrapCC (
(pkgsWin32.buildPackages.gcc-unwrapped.override
({
threadsCross = {
model = "win32";
package = null;
};
})).overrideAttrs (oldAttr: {
configureFlags = oldAttr.configureFlags ++ [
"--disable-sjlj-exceptions"
"--with-dwarf2"
];
})
);
targetCc = "${winCC}/bin/${winCC.targetPrefix}cc";
in
naerskWin.buildPackage {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
inherit version;
strictDeps = true;
src = pkgs.lib.cleanSource ../.;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
pkgs.nasm # aws-lc-sys requires it
];
depsBuildBuild = [
winCC
];
buildInputs = [
pkgsWin32.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${targetCc}";
CFLAGS_i686_pc_windows_gnu = "-I${pkgsWin32.windows.pthreads}/include";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${targetCc}"
"-L"
"native=${pkgsWin32.windows.pthreads}/lib"
];
CC = "${targetCc}";
LD = "${targetCc}";
}

View File

@@ -1,47 +0,0 @@
{ pkgs, naersk, fenixPkgs, system, version }:
packageName:
let
pkgsWin64 = pkgs.pkgsCross.mingwW64;
rustTarget = pkgsWin64.stdenv.hostPlatform.rust.rustcTarget;
toolchainWin = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.targets.${rustTarget}.stable.rust-std
];
naerskWin = pkgs.callPackage naersk {
cargo = toolchainWin;
rustc = toolchainWin;
};
targetCc = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
in
naerskWin.buildPackage {
pname = packageName;
cargoBuildOptions = x: x ++ [ "--package" packageName ];
inherit version;
strictDeps = true;
src = pkgs.lib.cleanSource ../.;
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
];
depsBuildBuild = [
pkgsWin64.stdenv.cc
];
buildInputs = [
pkgsWin64.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
doCheck = false; # Disable test as it requires network access.
CARGO_BUILD_TARGET = rustTarget;
TARGET_CC = "${targetCc}";
CFLAGS_x86_64_pc_windows_gnu = "-I${pkgsWin64.windows.pthreads}/include";
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${targetCc}"
"-L"
"native=${pkgsWin64.windows.pthreads}/lib"
];
CC = "${targetCc}";
LD = "${targetCc}";
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.54.0-dev"
version = "2.55.0"
license = "MPL-2.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"

View File

@@ -1 +1 @@
2026-06-15
2026-07-20

View File

@@ -115,35 +115,40 @@ impl Chatlist {
ChatId::new(0)
};
// select with left join and minimum:
//
// - the inner select must use `hidden` and _not_ `m.hidden`
// which would refer the outer select and take a lot of time
// - `GROUP BY` is needed several messages may have the same
// timestamp
// - the list starts with the newest chats
macro_rules! last_visible_msg_id_in {
($chat_id:expr) => {
concat!(
"
(SELECT id FROM msgs WHERE
-- state=`OutDraft`.
state=19 AND hidden=1 AND chat_id=",
$chat_id,
" OR
-- `InFresh`...`OutDelivered` inclusive, except `OutDraft`.
state IN (10,13,16,20,24,26) AND hidden=0 AND chat_id=",
$chat_id,
"
ORDER BY timestamp DESC, id DESC LIMIT 1)"
)
};
}
// The list starts with the newest chats.
//
// The query shows messages from blocked contacts in
// groups. Otherwise it would be hard to follow conversations.
let ids = if let Some(query_contact_id) = query_contact_id {
// show chats shared with a given contact
context.sql.query_map_vec(
context.sql.query_map_vec(concat!(
"SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
ON c.id=m.chat_id
AND m.id=(
SELECT id
FROM msgs
WHERE chat_id=c.id
AND (hidden=0 OR state=?1)
ORDER BY timestamp DESC, id DESC LIMIT 1)
AND m.id=", last_visible_msg_id_in!("c.id"), "
WHERE c.id>9
AND c.blocked!=1
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?2 AND add_timestamp >= remove_timestamp)
GROUP BY c.id
ORDER BY c.archived=?3 DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, query_contact_id, ChatVisibility::Pinned),
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=? AND add_timestamp >= remove_timestamp)
ORDER BY c.archived=? DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC"),
(query_contact_id, ChatVisibility::Pinned),
process_row,
).await?
} else if flag_archived_only {
@@ -154,22 +159,21 @@ impl Chatlist {
context
.sql
.query_map_vec(
"SELECT c.id, m.id
concat!(
"
SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
ON c.id=m.chat_id
AND m.id=(
SELECT id
FROM msgs
WHERE chat_id=c.id
AND (hidden=0 OR state=?)
ORDER BY timestamp DESC, id DESC LIMIT 1)
AND m.id=",
last_visible_msg_id_in!("c.id"),
"
WHERE c.id>9
AND c.blocked!=1
AND c.archived=1
GROUP BY c.id
ORDER BY IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft,),
ORDER BY IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC"
),
(),
process_row,
)
.await?
@@ -188,24 +192,18 @@ impl Chatlist {
let str_like_cmd = format!("%{}%", query.to_lowercase());
context
.sql
.query_map_vec(
.query_map_vec(concat!(
"SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
ON c.id=m.chat_id
AND m.id=(
SELECT id
FROM msgs
WHERE chat_id=c.id
AND (hidden=0 OR state=?1)
ORDER BY timestamp DESC, id DESC LIMIT 1)
WHERE c.id>9 AND c.id!=?2
AND m.id=", last_visible_msg_id_in!("c.id"), "
WHERE c.id>9 AND c.id!=?
AND c.blocked!=1
AND IFNULL(c.name_normalized,c.name) LIKE ?3
AND (NOT ?4 OR EXISTS (SELECT 1 FROM msgs m WHERE m.chat_id = c.id AND m.state == ?5 AND hidden=0))
GROUP BY c.id
ORDER BY IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, skip_id, str_like_cmd, only_unread, MessageState::InFresh),
AND IFNULL(c.name_normalized,c.name) LIKE ?
AND (NOT ? OR EXISTS (SELECT 1 FROM msgs m WHERE m.chat_id = c.id AND m.state == ? AND hidden=0))
ORDER BY IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC"),
(skip_id, str_like_cmd, only_unread, MessageState::InFresh),
process_row,
)
.await?
@@ -237,25 +235,19 @@ impl Chatlist {
})
.collect::<std::result::Result<Vec<_>, _>>()
};
context.sql.query_map(
context.sql.query_map(concat!(
"SELECT c.id, c.type, c.param, m.id
FROM chats c
LEFT JOIN msgs m
ON c.id=m.chat_id
AND m.id=(
SELECT id
FROM msgs
WHERE chat_id=c.id
AND (hidden=0 OR state=?)
ORDER BY timestamp DESC, id DESC LIMIT 1)
AND m.id=", last_visible_msg_id_in!("c.id"), "
WHERE c.id>9 AND c.id!=?
AND c.blocked=0
AND NOT c.archived=?
AND (c.type!=? OR c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=? AND add_timestamp >= remove_timestamp))
GROUP BY c.id
ORDER BY c.id=? DESC, c.archived=? DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
ORDER BY c.id=? DESC, c.archived=? DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC"),
(
MessageState::OutDraft, skip_id, ChatVisibility::Archived,
skip_id, ChatVisibility::Archived,
Chattype::Group, ContactId::SELF,
sort_id_up, ChatVisibility::Pinned,
),
@@ -264,23 +256,17 @@ impl Chatlist {
).await?
} else {
// show normal chatlist
context.sql.query_map_vec(
context.sql.query_map_vec(concat!(
"SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
ON c.id=m.chat_id
AND m.id=(
SELECT id
FROM msgs
WHERE chat_id=c.id
AND (hidden=0 OR state=?)
ORDER BY timestamp DESC, id DESC LIMIT 1)
AND m.id=", last_visible_msg_id_in!("c.id"), "
WHERE c.id>9 AND c.id!=?
AND (c.blocked=0 OR c.blocked=2)
AND NOT c.archived=?
GROUP BY c.id
ORDER BY c.id=0 DESC, c.archived=? DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, skip_id, ChatVisibility::Archived, ChatVisibility::Pinned),
ORDER BY c.id=0 DESC, c.archived=? DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC"),
(skip_id, ChatVisibility::Archived, ChatVisibility::Pinned),
process_row,
).await?
};

View File

@@ -45,9 +45,10 @@ use crate::transport::{
use crate::{EventType, stock_str};
use crate::{chat, provider};
/// Maximum number of relays
/// see <https://github.com/chatmail/core/issues/7608>
pub(crate) const MAX_TRANSPORT_RELAYS: usize = 5;
/// Maximum number of relays.
///
/// See <https://github.com/chatmail/core/issues/7608>.
pub(crate) const MAX_RELAYS: usize = 5;
/// Hard-coded candidates for default relays.
/// In the future, we want to use it during onboarding;
@@ -231,8 +232,8 @@ impl Context {
}
/// Immediately deletes a transport, potentially causing messages not to arrive.
/// This must ONLY be used by the automated tests.
/// UI implementations must use `set_transport_unpublished()` instead.
/// This must ONLY be used internally and by the automated tests.
/// UI implementations must use [`Self::set_transport_unpublished`] instead.
pub async fn delete_transport(&self, addr: &str) -> Result<()> {
let now = time();
let removed_transport_id = self
@@ -291,6 +292,12 @@ impl Context {
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know our new transport addresses yet.
///
/// When more transports are added by [`Self::add_or_update_transport()`] or [`Self::add_transport_from_qr`],
/// the least recently needed unpublished transport is automatically removed
/// if this is necessary in order to stay below the maximum number of allowed relays.
/// Also, unpublished transports that are not used to receive any new messages for a time defined by
/// `UNPUBLISHED_TRANSPORT_KEEP_TIME` are automatically removed.
pub async fn set_transport_unpublished(&self, addr: &str, unpublished: bool) -> Result<()> {
self.sql
.transaction(|trans| {
@@ -322,25 +329,15 @@ impl Context {
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
info!(self, "Configure ...");
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
if old_addr.is_some()
&& !self
.sql
.exists(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(&param.addr,),
)
.await?
&& self
.sql
.count("SELECT COUNT(*) FROM transports", ())
.await?
>= MAX_TRANSPORT_RELAYS
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
if !self
.sql
.exists(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(&param.addr,),
)
.await?
{
self.try_make_space_for_new_relay().await?;
}
let provider = match configure(self, param).await {
@@ -364,6 +361,42 @@ impl Context {
on_configure_completed(self, provider).await?;
Ok(())
}
/// This function is called before adding a new relay.
/// If the maximum number of relays ([`MAX_RELAYS`]) is already reached,
/// then it tries to make space by removing an unpublished relay.
/// If there are multiple unpublished relays,
/// the one that hasn't received a message for longest is removed.
/// If there are no unpublished relays, an error is returned.
///
/// Note that eviction happens before we know that a new relay works,
/// which is a trade-off we made in favor of implementation complexity.
async fn try_make_space_for_new_relay(&self) -> Result<()> {
if self.count_transports().await? >= MAX_RELAYS {
// Try to automatically remove the unpublished transport that wasn't used for the longest time:
if let Some(addr) = self
.sql
.query_get_value::<String>(
"SELECT addr FROM transports WHERE is_published=0
ORDER BY last_rcvd_timestamp, add_timestamp LIMIT 1",
(),
)
.await?
{
info!(
self,
"Auto-deleting relay {addr} to make space for new relay."
);
self.delete_transport(&addr).await?;
}
if self.count_transports().await? >= MAX_RELAYS {
// Apparently, all the transports are published
bail!("You have reached the maximum number of relays ({MAX_RELAYS})");
}
};
Ok(())
}
}
async fn on_configure_completed(
@@ -810,10 +843,14 @@ pub enum Error {
#[cfg(test)]
mod tests {
use crate::tools::SystemTime;
use super::*;
use crate::config::Config;
use crate::login_param::EnteredImapLoginParam;
use crate::sql::update_transport_last_rcvd_timestamp;
use crate::test_utils::TestContext;
use crate::transport::add_pseudo_transport;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_panic_on_bad_credentials() {
@@ -844,4 +881,107 @@ mod tests {
assert_eq!(configured_param.smtp_user, "");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_try_make_place_for_new_relay() -> Result<()> {
let t = TestContext::new().await;
// Setting ConfiguredAddr on an unconfigured account creates a pseudo primary transport
t.set_config(Config::ConfiguredAddr, Some("primary@example.org"))
.await?;
// Test that try_make_place_for_new_relay() doesn't do anything when we're below the limit
assert_eq!(t.count_transports().await?, 1);
t.try_make_space_for_new_relay().await?;
assert_eq!(t.count_transports().await?, 1);
for i in 0..(MAX_RELAYS - 2) {
add_pseudo_transport(&t, &format!("transport{i}@example.org")).await?;
}
assert_eq!(t.count_transports().await?, MAX_RELAYS - 1);
t.try_make_space_for_new_relay().await?;
assert_eq!(t.count_transports().await?, MAX_RELAYS - 1);
// Test that try_make_place_for_new_relay() removes the unpublished transport
// when we're at the limit
add_pseudo_transport(&t, "unpublished@example.org").await?;
t.set_transport_unpublished("unpublished@example.org", true)
.await?;
assert_eq!(t.count_transports().await?, MAX_RELAYS);
t.try_make_space_for_new_relay().await?;
assert_eq!(t.count_transports().await?, MAX_RELAYS - 1);
assert_eq!(
t.sql
.exists(
"SELECT COUNT(*) FROM transports WHERE addr=?",
("unpublished@example.org",),
)
.await?,
false
);
// Test that if there are multiple unpublished relays,
// the one that was used least recently is removed
t.set_transport_unpublished("transport0@example.org", true)
.await?;
add_pseudo_transport(&t, "other_unpublished@example.org").await?;
t.set_transport_unpublished("other_unpublished@example.org", true)
.await?;
assert_eq!(t.count_transports().await?, MAX_RELAYS);
let transport0_id: u32 = t
.sql
.query_get_value(
"SELECT id FROM transports WHERE addr=?",
("transport0@example.org",),
)
.await?
.unwrap();
let other_unpublished_id: u32 = t
.sql
.query_get_value(
"SELECT id FROM transports WHERE addr=?",
("other_unpublished@example.org",),
)
.await?
.unwrap();
update_transport_last_rcvd_timestamp(&t, transport0_id).await?;
SystemTime::shift(std::time::Duration::from_secs(10));
update_transport_last_rcvd_timestamp(&t, other_unpublished_id).await?;
// Test that try_make_place_for_new_relay()
// removes the relay with the oldest last_rcvd_timestamp
t.try_make_space_for_new_relay().await?;
assert_eq!(t.count_transports().await?, MAX_RELAYS - 1);
assert_eq!(
t.sql
.exists(
"SELECT COUNT(*) FROM transports WHERE addr=?",
("transport0@example.org",),
)
.await?,
false
);
assert_eq!(
t.sql
.exists(
"SELECT COUNT(*) FROM transports WHERE addr=?",
("other_unpublished@example.org",),
)
.await?,
true
);
// Test that try_make_place_for_new_relay() fails
// if there are MAX_RELAYS published transports
add_pseudo_transport(&t, "published_extra@example.org").await?;
t.set_transport_unpublished("other_unpublished@example.org", false)
.await?;
assert_eq!(t.count_transports().await?, MAX_RELAYS);
assert!(t.try_make_space_for_new_relay().await.is_err());
assert_eq!(t.count_transports().await?, MAX_RELAYS);
Ok(())
}
}

View File

@@ -132,7 +132,7 @@ impl Message {
}
/// Actually downloads a message partially downloaded before if the message is available on the
/// session transport, in which case returns `Some`. If the message is available on another
/// session transport, in which case returns `Some`. If the message is not available on this
/// transport, returns `None`.
///
/// Most messages are downloaded automatically on fetch instead.
@@ -145,29 +145,24 @@ pub(crate) async fn download_msg(
let row = context
.sql
.query_row_optional(
"SELECT uid, folder, transport_id FROM imap
"SELECT uid, folder FROM imap
WHERE rfc724_mid=? AND target!=''
ORDER BY transport_id=? DESC LIMIT 1",
AND transport_id=?
LIMIT 1",
(&rfc724_mid, transport_id),
|row| {
let server_uid: u32 = row.get(0)?;
let server_folder: String = row.get(1)?;
let msg_transport_id: u32 = row.get(2)?;
Ok((server_uid, server_folder, msg_transport_id))
Ok((server_uid, server_folder))
},
)
.await?;
let Some((server_uid, server_folder, msg_transport_id)) = row else {
// No IMAP record found, we don't know the UID and folder.
let Some((server_uid, server_folder)) = row else {
// No IMAP record found, the message is not available on this transport.
delete_from_available_post_msgs(context, &rfc724_mid).await?;
return Err(anyhow!(
"IMAP location for {rfc724_mid:?} post-message is unknown"
));
};
if msg_transport_id != transport_id {
return Ok(None);
}
};
Box::pin(session.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)).await?;
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
@@ -283,35 +278,39 @@ pub(crate) async fn download_msgs(context: &Context, session: &mut Session) -> R
.await?;
for rfc724_mid in &rfc724_mids {
let res = download_msg(context, rfc724_mid.clone(), session).await;
if let Ok(Some(())) = res {
delete_from_downloads(context, rfc724_mid).await?;
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {
warn!(
context,
"Failed to download message rfc724_mid={rfc724_mid}: {:#}.", err
);
if !msg_is_downloaded_for(context, rfc724_mid).await? {
// This is probably a classical email that vanished before we could download it
warn!(
context,
"{rfc724_mid} download failed and there is no downloaded pre-message."
);
delete_from_downloads(context, rfc724_mid).await?;
} else if available_post_msgs_contains_rfc724_mid(context, rfc724_mid).await? {
warn!(
context,
"{rfc724_mid} is in available_post_msgs table but we failed to fetch it,
so set the message to DownloadState::Failure - probably it was deleted on the server in the meantime"
);
set_state_to_failure(context, rfc724_mid).await?;
match download_msg(context, rfc724_mid.clone(), session).await {
Ok(Some(())) => {
delete_from_downloads(context, rfc724_mid).await?;
delete_from_available_post_msgs(context, rfc724_mid).await?;
} else {
// leave the message in DownloadState::InProgress;
// it will be downloaded once it arrives.
}
Ok(None) => {
// The message is not available on this transport.
}
Err(err) => {
warn!(
context,
"Failed to download message rfc724_mid={rfc724_mid}: {:#}.", err
);
if !msg_is_downloaded_for(context, rfc724_mid).await? {
// This is probably a classical email that vanished before we could download it
warn!(
context,
"{rfc724_mid} download failed and there is no downloaded pre-message."
);
delete_from_downloads(context, rfc724_mid).await?;
} else if available_post_msgs_contains_rfc724_mid(context, rfc724_mid).await? {
warn!(
context,
"{rfc724_mid} is in available_post_msgs table but we failed to fetch it,
so set the message to DownloadState::Failure - probably it was deleted on the server in the meantime"
);
set_state_to_failure(context, rfc724_mid).await?;
delete_from_downloads(context, rfc724_mid).await?;
delete_from_available_post_msgs(context, rfc724_mid).await?;
} else {
// leave the message in DownloadState::InProgress;
// it will be downloaded once it arrives.
}
}
}
}

View File

@@ -21,6 +21,7 @@ use crate::message::{Message, MsgId};
use crate::mimeparser::parse_message_id;
use crate::param::{Param::SendHtml, Params};
use crate::plaintext::PlainText;
use crate::simplify::unescape_message_footer_marks;
use crate::sql;
use crate::tools::{buf_compress, buf_decompress};
@@ -174,14 +175,14 @@ impl HtmlMsgParser {
if self.html.is_empty()
&& let Ok(decoded_data) = mail.get_body()
{
self.html = decoded_data;
self.html = unescape_message_footer_marks(&decoded_data);
}
} else if mimetype == mime::TEXT_PLAIN
&& self.plain.is_none()
&& let Ok(decoded_data) = mail.get_body()
{
self.plain = Some(PlainText {
text: decoded_data,
text: unescape_message_footer_marks(&decoded_data),
flowed: if let Some(format) = mail.ctype.params.get("format") {
format.as_str().eq_ignore_ascii_case("flowed")
} else {
@@ -417,6 +418,25 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_htmlparse_plain_escaped_footer() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_plain_escaped_footer.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
assert_eq!(
parser.html,
r#"<!DOCTYPE html>
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="color-scheme" content="light dark" />
</head><body dir="auto" style="unicode-bidi: plaintext">
-- escaped footer 1<br/>
-- escaped footer 2<br/>
</body></html>
"#
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_htmlparse_html() {
let t = TestContext::new().await;

View File

@@ -350,8 +350,8 @@ impl Imap {
let login_params = prioritize_server_login_params(&context.sql, &self.lp, "imap").await?;
let mut first_error = None;
for lp in login_params {
info!(context, "IMAP trying to connect to {}.", &lp.connection);
'candidate: for lp in login_params {
info!(context, "IMAP trying to connect to {}.", lp.connection);
let connection_candidate = lp.connection.clone();
let client = match Client::connect(
context,
@@ -366,7 +366,7 @@ impl Imap {
Err(err) => {
warn!(context, "{err:#}.");
first_error.get_or_insert(err);
continue;
continue 'candidate;
}
};
@@ -387,26 +387,47 @@ impl Imap {
user: imap_user.into(),
access_token: token,
};
client.authenticate("XOAUTH2", auth).await
client
.authenticate("XOAUTH2", auth)
.await
.map(|session| (session, None))
} else {
info!(context, "Logging into IMAP server with LOGIN.");
client.login(imap_user, imap_pw).await
};
match login_res {
Ok(mut session) => {
let capabilities = determine_capabilities(&mut session).await?;
Ok((mut session, login_capabilities_opt)) => {
let capabilities = if let Some(login_capabilities) = login_capabilities_opt {
login_capabilities
} else {
// OK response did not contain the CAPABILITY response code.
// Request capabilities explicitly.
match determine_capabilities(&mut session).await {
Ok(capabilities) => capabilities,
Err(err) => {
warn!(context, "Failed to determine capabilities: {err:#}.");
continue 'candidate;
}
}
};
let resync_request_sender = self.resync_request_sender.clone();
let session = if capabilities.can_compress {
info!(context, "Enabling IMAP compression.");
let compressed_session = session
let compressed_session = match session
.compress(|s| {
let session_stream: Box<dyn SessionStream> = Box::new(s);
session_stream
})
.await
.context("Failed to enable IMAP compression")?;
{
Ok(compressed_session) => compressed_session,
Err(err) => {
warn!(context, "Failed to enable IMAP compression: {err:#}.");
continue 'candidate;
}
};
Session::new(
compressed_session,
capabilities,
@@ -1182,6 +1203,7 @@ impl Session {
for (request_uids, set) in build_sequence_sets(&request_uids)? {
info!(context, "Starting UID FETCH of message set \"{}\".", set);
let transport_id = self.transport_id();
let mut fetch_responses = self
.uid_fetch(&set, BODY_FULL)
.await
@@ -1277,6 +1299,12 @@ impl Session {
"Passing message UID {} to receive_imf().", request_uid
);
let res = receive_imf_inner(context, rfc724_mid, body, is_seen).await;
crate::sql::update_transport_last_rcvd_timestamp(context, transport_id)
.await
.context(format!(
"Failed to update last_rcvd_timestamp of transport {}",
transport_id
))?;
// If there was an error receiving the message, show a device message:
let received_msg = match res {

View File

@@ -53,10 +53,24 @@ fn alpn(port: u16) -> &'static str {
pub(crate) async fn determine_capabilities(
session: &mut ImapSession<Box<dyn SessionStream>>,
) -> Result<Capabilities> {
let caps = session
let imap_capabilities = session
.capabilities()
.await
.context("CAPABILITY command error")?;
identify_server(session, imap_capabilities).await
}
/// Identifies the server by sending ID command if it is supported.
///
/// Some IMAP servers require sending this command,
/// see <https://github.com/chatmail/core/issues/3458>,
/// <https://github.com/OfflineIMAP/offlineimap3/issues/71>
/// and <https://github.com/pimalaya/himalaya/issues/652>
/// for details.
pub(crate) async fn identify_server(
session: &mut ImapSession<Box<dyn SessionStream>>,
caps: async_imap::types::Capabilities,
) -> Result<Capabilities> {
let server_id = if caps.has_str("ID") {
session.id([("name", Some("Delta Chat"))]).await?
} else {
@@ -82,18 +96,30 @@ impl Client {
}
}
/// Logs in with the LOGIN command.
///
/// If the server supports [ID extension], sends ID command
/// and records the server response in the [`Capabilities`] structure.
///
/// [ID extension]: https://datatracker.ietf.org/doc/rfc2971/
pub(crate) async fn login(
self,
username: &str,
password: &str,
) -> Result<ImapSession<Box<dyn SessionStream>>> {
) -> Result<(ImapSession<Box<dyn SessionStream>>, Option<Capabilities>)> {
let Client { inner, .. } = self;
let session = inner
.login(username, password)
let (mut session, login_capabilities_opt) = inner
.login_with_capabilities(username, password)
.await
.map_err(|(err, _client)| err)?;
Ok(session)
let capabilities = if let Some(login_capabilities) = login_capabilities_opt {
Some(identify_server(&mut session, login_capabilities).await?)
} else {
None
};
Ok((session, capabilities))
}
pub(crate) async fn authenticate(

View File

@@ -18,7 +18,7 @@ use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt;
use anyhow::Result;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use crate::chat::{Chat, ChatId, send_msg};
@@ -199,6 +199,39 @@ async fn set_msg_id_reaction(
Ok(())
}
/// Adds or updates a pending reaction to `pending_reactions` table.
async fn set_pending_reaction(
context: &Context,
rfc724_mid: &str,
contact_id: ContactId,
timestamp: i64,
reaction: &Reaction,
) -> Result<()> {
if reaction.is_empty() {
context
.sql
.execute(
"DELETE FROM pending_reactions
WHERE rfc724_mid = ?1
AND contact_id = ?2",
(rfc724_mid, contact_id),
)
.await?;
} else {
context
.sql
.execute(
"INSERT INTO pending_reactions (rfc724_mid, contact_id, reaction, timestamp)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(rfc724_mid, contact_id)
DO UPDATE SET reaction=excluded.reaction, timestamp=excluded.timestamp",
(rfc724_mid, contact_id, reaction.as_str(), timestamp),
)
.await?;
}
Ok(())
}
/// Sends a reaction to message `msg_id`, overriding previously sent reactions.
///
/// `reaction` is a string consisting of a single emoji. Use
@@ -235,6 +268,9 @@ pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) ->
///
/// `reaction` is string representing the emoji. It can be empty
/// if contact wants to remove the reaction.
///
/// Delegates to [`set_msg_reaction_if_present`],
/// and if message is not present, fallbacks to [`set_pending_reaction`].
pub(crate) async fn set_msg_reaction(
context: &Context,
in_reply_to: &str,
@@ -244,8 +280,43 @@ pub(crate) async fn set_msg_reaction(
reaction: Reaction,
is_incoming_fresh: bool,
) -> Result<()> {
if !set_msg_reaction_if_present(
context,
in_reply_to,
chat_id,
contact_id,
timestamp,
&reaction,
is_incoming_fresh,
)
.await?
{
info!(
context,
"Can't assign reaction to unknown message with Message-ID {}; inserting into pending table.",
in_reply_to
);
let rfc724_mid = in_reply_to.trim_start_matches('<').trim_end_matches('>');
set_pending_reaction(context, rfc724_mid, contact_id, timestamp, &reaction).await?
}
Ok(())
}
/// Similar to [`set_msg_reaction`],
/// but does not create a row in `pending_messages` if message is not present.
///
/// Returns `Ok(true)` if message was present and `Ok(false)` if not.
pub(crate) async fn set_msg_reaction_if_present(
context: &Context,
in_reply_to: &str,
chat_id: ChatId,
contact_id: ContactId,
timestamp: i64,
reaction: &Reaction,
is_incoming_fresh: bool,
) -> Result<bool> {
if let Some(msg_id) = rfc724_mid_exists(context, in_reply_to).await? {
set_msg_id_reaction(context, msg_id, chat_id, contact_id, timestamp, &reaction).await?;
set_msg_id_reaction(context, msg_id, chat_id, contact_id, timestamp, reaction).await?;
if is_incoming_fresh
&& !reaction.is_empty()
@@ -255,15 +326,82 @@ pub(crate) async fn set_msg_reaction(
chat_id,
contact_id,
msg_id,
reaction,
reaction: reaction.clone(),
});
}
} else {
info!(
context,
"Can't assign reaction to unknown message with Message-ID {}", in_reply_to
);
return Ok(true);
}
Ok(false)
}
/// Applies pending reactions to message `rfc724_mid`, assuming `chat_id`.
///
/// Does not check if the `chat_id` is correct for this message.
pub(crate) async fn apply_pending_reactions(
context: &Context,
rfc724_mid: &str,
chat_id: ChatId,
) -> Result<()> {
let pending_reactions: BTreeMap<ContactId, (Reaction, i64)> = context
.sql
.query_map_collect(
"SELECT contact_id, reaction, timestamp
FROM pending_reactions
WHERE rfc724_mid=?",
(rfc724_mid,),
|row| {
let contact_id: ContactId = row.get(0)?;
let reaction: Reaction = Reaction::new(row.get::<_, String>(1)?.as_str());
let timestamp: i64 = row.get(2)?;
Ok((contact_id, (reaction, timestamp)))
},
)
.await?;
if pending_reactions.is_empty() {
return Ok(());
}
info!(
context,
"Applying {} pending reactions to {}.",
pending_reactions.len(),
rfc724_mid
);
for (contact_id, (reaction, timestamp)) in pending_reactions {
// We know whether the reaction message is incoming,
// but it is unclear whether it's still fresh (i.e. unnoticed by the user).
// To be safe, always count it as fresh, to notify the user rather once too often than once too few.
// This is only relevant in edge cases, anyway.
let is_incoming_fresh = contact_id != ContactId::SELF;
if !set_msg_reaction_if_present(
context,
rfc724_mid,
chat_id,
contact_id,
timestamp,
&reaction,
is_incoming_fresh,
)
.await?
{
bail!("Message {rfc724_mid} is not present, can't apply pending reactions");
}
}
// Note: race condition can't happen here,
// as at this point the message is already added to the DB,
// so no new pending reactions with this rfc724_mid will be added in meantime.
// (Assuming this function is used after receiving the message.)
context
.sql
.execute(
"DELETE FROM pending_reactions WHERE rfc724_mid=?",
(rfc724_mid,),
)
.await?;
Ok(())
}
@@ -713,6 +851,71 @@ Content-Disposition: reaction\n\
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_out_of_order_reaction() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let charlie = tcm.charlie().await;
tcm.send_recv_accept(&alice, &bob, "hi").await;
tcm.send_recv_accept(&alice, &charlie, "hi").await;
let chat_alice = alice.create_chat(&bob).await;
let groupchat_alice = alice
.create_group_with_members("test", &[&bob, &charlie])
.await;
// one to one
{
let alice_msg = alice.send_text(chat_alice.id, "test").await;
send_reaction(&alice, alice_msg.sender_msg_id, "👍").await?;
let reaction_msg = alice.pop_sent_msg().await;
bob.recv_msg_hidden(&reaction_msg).await;
let msg = bob.recv_msg(&alice_msg).await;
assert_eq!(get_msg_reactions(&bob, msg.id).await?.reactions.len(), 1);
}
// group
{
let alice_msg = alice.send_text(groupchat_alice, "test").await;
send_reaction(&alice, alice_msg.sender_msg_id, "👍").await?;
let reaction_msg_alice = alice.pop_sent_msg().await;
let charlie_msg = charlie.recv_msg(&alice_msg).await;
send_reaction(&charlie, charlie_msg.id, "👍").await?;
let reaction_msg_charlie = charlie.pop_sent_msg().await;
bob.recv_msg_hidden(&reaction_msg_alice).await;
bob.recv_msg_hidden(&reaction_msg_charlie).await;
let msg = bob.recv_msg(&alice_msg).await;
assert_eq!(get_msg_reactions(&bob, msg.id).await?.reactions.len(), 2);
}
// react and remove reaction
{
let alice_msg = alice.send_text(chat_alice.id, "test").await;
send_reaction(&alice, alice_msg.sender_msg_id, "👍").await?;
let reaction_msg = alice.pop_sent_msg().await;
send_reaction(&alice, alice_msg.sender_msg_id, "").await?;
let remove_reaction_msg = alice.pop_sent_msg().await;
bob.recv_msg_hidden(&reaction_msg).await;
bob.recv_msg_hidden(&remove_reaction_msg).await;
let msg = bob.recv_msg(&alice_msg).await;
assert_eq!(get_msg_reactions(&bob, msg.id).await?.reactions.len(), 0);
}
Ok(())
}
async fn assert_summary(t: &TestContext, expected: &str) {
let chatlist = Chatlist::try_load(t, 0, None, None).await.unwrap();
let summary = chatlist.get_summary(t, 0, None).await.unwrap();

View File

@@ -724,7 +724,7 @@ pub(crate) async fn receive_imf_inner(
is_old_contact_request = chat_id_blocked == Blocked::Request && !is_created;
// Add parts
add_parts(
let msg = add_parts(
context,
&mut mime_parser,
imf_raw,
@@ -740,7 +740,13 @@ pub(crate) async fn receive_imf_inner(
is_created,
)
.await
.context("add_parts error")?
.context("add_parts error")?;
crate::reaction::apply_pending_reactions(context, rfc724_mid, chat_id)
.await
.context("Failed to apply pending reactions")?;
msg
};
if !from_id.is_special() {

View File

@@ -1,7 +1,7 @@
//! # Simplify incoming plaintext.
use crate::tools::IsNoneOrEmpty;
/// Protects lines starting with `--` against being treated as a footer.
/// Protects lines starting with `-- ` against being treated as a footer.
/// for that, we insert a ZERO WIDTH SPACE (ZWSP, 0x200B);
/// this should be invisible on most systems and there is no need to unescape it again
/// (which won't be done by non-deltas anyway).
@@ -10,10 +10,27 @@ use crate::tools::IsNoneOrEmpty;
/// but for non-delta-compatibility, that seems to be better.
/// (to be only compatible with delta, only "[\r\n|\n]-- {0,2}[\r\n|\n]" needs to be replaced)
pub fn escape_message_footer_marks(text: &str) -> String {
if let Some(text) = text.strip_prefix("--") {
"-\u{200B}-".to_string() + &text.replace("\n--", "\n-\u{200B}-")
if let Some(text) = text.strip_prefix("-- ") {
"-\u{200B}- ".to_string() + &text.replace("\n-- ", "\n-\u{200B}- ")
} else {
text.replace("\n--", "\n-\u{200B}-")
text.replace("\n-- ", "\n-\u{200B}- ")
}
}
/// Unapplies changes made by [`escape_message_footer_marks`].
///
/// This assumes original message doesn't contain `LF DASH ZWSP DASH` (`\n-\u{200B}-`)
/// character sequence and doesn't start with `DASH ZWSP DASH` (`-\u{200B}-`)
/// character sequence.
/// Such sequences are unlikely to appear in normal text communication.
///
/// For backward compatibility, un-escaping as opposed to escaping
/// doesn't require the final space to be present.
pub fn unescape_message_footer_marks(text: &str) -> String {
if let Some(text) = text.strip_prefix("-\u{200B}-") {
"--".to_string() + &text.replace("\n-\u{200B}-", "\n--")
} else {
text.replace("\n-\u{200B}-", "\n--")
}
}
@@ -289,8 +306,7 @@ fn render_message(lines: &[&str], is_cut_at_end: bool) -> String {
if is_cut_at_end && !ret.is_empty() {
ret += " [...]";
}
// redo escaping done by escape_message_footer_marks()
ret.replace('\u{200B}', "")
unescape_message_footer_marks(&ret)
}
/// Returns true if the line contains only whitespace.
@@ -467,12 +483,21 @@ mod tests {
}
#[test]
fn test_escape_message_footer_marks() {
let esc = escape_message_footer_marks("--\n--text --in line");
assert_eq!(esc, "-\u{200B}-\n-\u{200B}-text --in line");
fn test_escape_unescape_message_footer_marks() {
let original = "-- \n-- text -- in line";
let esc = escape_message_footer_marks(original);
assert_eq!(esc, "-\u{200B}- \n-\u{200B}- text -- in line");
let unesc = unescape_message_footer_marks(&esc);
assert_eq!(unesc, original);
}
let esc = escape_message_footer_marks("--\r\n--text");
assert_eq!(esc, "-\u{200B}-\r\n-\u{200B}-text");
#[test]
fn test_escape_unescape_message_footer_marks_crlf() {
let original = "-- \r\n-- text";
let esc = escape_message_footer_marks(original);
assert_eq!(esc, "-\u{200B}- \r\n-\u{200B}- text");
let unesc = unescape_message_footer_marks(&esc);
assert_eq!(unesc, original);
}
#[test]
@@ -515,7 +540,7 @@ mod tests {
assert!(!is_cut);
assert_eq!(footer.unwrap(), "footer");
let input = "text\n\n--\ntreated as footer when unescaped".to_string();
let input = "text\n\n-- \ntreated as footer when unescaped".to_string();
let SimplifiedText {
text,
is_cut,
@@ -532,7 +557,7 @@ mod tests {
footer,
..
} = simplify(escaped, true);
assert_eq!(text, "text\n\n--\ntreated as footer when unescaped");
assert_eq!(text, "text\n\n-- \ntreated as footer when unescaped");
assert!(!is_cut);
assert_eq!(footer, None);
@@ -557,7 +582,7 @@ mod tests {
assert!(!is_cut);
assert_eq!(footer, None);
let input = "--\ntreated as footer when unescaped".to_string();
let input = "-- \ntreated as footer when unescaped".to_string();
let SimplifiedText {
text,
is_cut,
@@ -575,7 +600,7 @@ mod tests {
footer,
..
} = simplify(escaped, true);
assert_eq!(text, "--\ntreated as footer when unescaped");
assert_eq!(text, "-- \ntreated as footer when unescaped");
assert!(!is_cut);
assert_eq!(footer, None);
}

View File

@@ -47,6 +47,9 @@ mod pool;
use pool::{Pool, WalCheckpointStats};
/// How long a hidden and unused transport should be kept in the database before being deleted.
pub const UNPUBLISHED_TRANSPORT_KEEP_TIME: i64 = 90 * 24 * 60 * 60;
/// A wrapper around the underlying Sqlite3 object.
#[derive(Debug)]
pub struct Sql {
@@ -908,10 +911,71 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
.log_err(context)
.ok();
remove_unused_hidden_transports(context)
.await
.context("Failed to remove unused hidden transports")
.log_err(context)
.ok();
remove_old_pending_reactions(context)
.await
.context("Failed to remove old pending reactions")
.log_err(context)
.ok();
info!(context, "Housekeeping done.");
Ok(())
}
/// Removes pending reactions that weren't applied for 90 days.
async fn remove_old_pending_reactions(context: &Context) -> Result<usize> {
let three_months_ago = time().saturating_sub(60 * 60 * 24 * 90);
context
.sql
.execute(
"DELETE FROM pending_reactions WHERE timestamp < ?",
(three_months_ago,),
)
.await
}
/// Removes transports that are hidden (`is_published=0`),
/// and haven't been used to receive new messages for [`UNPUBLISHED_TRANSPORT_KEEP_TIME`] seconds.
pub(crate) async fn remove_unused_hidden_transports(context: &Context) -> Result<usize> {
let now = time();
let cutoff = now.saturating_sub(UNPUBLISHED_TRANSPORT_KEEP_TIME);
context
.sql
.execute(
"DELETE FROM transports
WHERE is_published=0
AND last_rcvd_timestamp<?1
AND add_timestamp<?1", // important, prevents immediate deletion in case of `last_rcvd_timestamp=0`
(cutoff,),
)
.await
}
/// Updates transport's `last_rcvd_timestamp`
/// with the current time.
///
/// This is used to postpone deletion of hidden transport by [`remove_unused_hidden_transports`],
/// if it is still used to receive messages.
pub(crate) async fn update_transport_last_rcvd_timestamp(
context: &Context,
transport_id: u32,
) -> Result<usize> {
context
.sql
.execute(
"UPDATE transports
SET last_rcvd_timestamp=?1
WHERE id=?2",
(time(), transport_id),
)
.await
}
/// Get the value of a column `idx` of the `row` as `Vec<u8>`.
pub fn row_get_vec(row: &Row, idx: usize) -> rusqlite::Result<Vec<u8>> {
row.get(idx).or_else(|err| match row.get_ref(idx)? {

View File

@@ -2456,6 +2456,72 @@ UPDATE msgs SET state=24 WHERE state=18; -- Change OutPreparing to OutFailed.
.await?;
}
inc_and_check(&mut migration_version, 155)?;
if dbversion < migration_version {
// last_rcvd_timestamp tracks timestamp of the last received message
// on the transport. This is used to automatically remove hidden transports
// that were not used to receive messages for some time.
//
// NOTE: ideally we would use `DEFAULT (unixepoch())`,
// but sqlite forbids non-constant default in ALTER TABLE.
// Instead, we need to remember to also check `add_timestamp`
// before removing transport
// (so it won't be immediately deleted if last_rcvd_timestamp=0).
sql.execute_migration(
"
ALTER TABLE transports
ADD last_rcvd_timestamp INTEGER NOT NULL DEFAULT 0
",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 156)?;
if dbversion < migration_version {
sql.execute_migration(
"DROP INDEX IF EXISTS msgs_index7;
CREATE INDEX msgs_index7 ON msgs (state, hidden, chat_id, timestamp);",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 157)?;
if dbversion < migration_version {
// Ensure that existing transports
// don't start out with last_rcvd_timestamp=0,
// so that they are not immediately deleted
sql.execute_migration_transaction(
|transaction| {
transaction.execute(
"UPDATE transports SET last_rcvd_timestamp=?1 WHERE last_rcvd_timestamp=0",
(tools::time(),),
)?;
Ok(())
},
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 158)?;
if dbversion < migration_version {
// Stores reactions to not-yet-received messages.
sql.execute_migration(
"CREATE TABLE pending_reactions (
rfc724_mid TEXT NOT NULL,
contact_id INTEGER NOT NULL,
reaction TEXT NOT NULL,
timestamp INTEGER NOT NULL,
PRIMARY KEY(rfc724_mid, contact_id),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
) STRICT",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::message::Message;
use crate::tools::SystemTime;
use crate::{EventType, test_utils::TestContext};
#[test]
@@ -367,3 +368,114 @@ async fn test_incremental_vacuum() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_remove_unused_hidden_transports() -> Result<()> {
let t = TestContext::new().await;
let now = time();
let transport_id = t
.sql
.transaction(|t| {
// published transport
t.execute(
"INSERT INTO transports
(addr,
entered_param,
configured_param,
add_timestamp)
VALUES (?, ?, ?, ?)",
("1", "", "", now),
)?;
// not published transport
let transport_id = t.query_row::<u32, _, _>(
"INSERT INTO transports
(addr,
entered_param,
configured_param,
is_published,
add_timestamp)
VALUES (?, ?, ?, ?, ?)
RETURNING id",
("2", "", "", 0, now),
|row| row.get(0),
)?;
// this transport has add_timestamp in the future,
// and should not be removed at all, since we only shift time
// by 90 days and one second.
t.execute(
"INSERT INTO transports
(addr,
entered_param,
configured_param,
is_published,
add_timestamp)
VALUES (?, ?, ?, ?, ?)",
("3", "", "", 0, now + 60),
)?;
Ok(transport_id)
})
.await?;
async fn assert_transport_removed(
context: &Context,
hidden_transport_id: u32,
should_be_removed: bool,
) -> Result<()> {
let transports_count: u32 = context
.sql
.query_get_value("SELECT count(*) FROM transports", ())
.await?
.unwrap();
assert_eq!(transports_count, if should_be_removed { 2 } else { 3 });
let hidden_transport_present: bool = context
.sql
.query_get_value(
"SELECT count(*) FROM transports WHERE id=?",
(hidden_transport_id,),
)
.await?
.unwrap();
assert_eq!(hidden_transport_present, !should_be_removed);
Ok(())
}
assert_eq!(
t.sql
.query_get_value::<i64>(
"SELECT last_rcvd_timestamp FROM transports WHERE id=?",
(transport_id,)
)
.await?
.unwrap(),
0
);
update_transport_last_rcvd_timestamp(&t, transport_id).await?;
// last_rcvd_timestamp should update
assert!(
t.sql
.query_get_value::<i64>(
"SELECT last_rcvd_timestamp FROM transports WHERE id=?",
(transport_id,)
)
.await?
.unwrap()
>= now
);
assert_transport_removed(&t, transport_id, false).await?;
remove_unused_hidden_transports(&t).await?;
// it was recently used, so nothing is deleted
assert_transport_removed(&t, transport_id, false).await?;
SystemTime::shift(Duration::from_secs(
(UNPUBLISHED_TRANSPORT_KEEP_TIME + 1).try_into()?,
));
remove_unused_hidden_transports(&t).await?;
assert_transport_removed(&t, transport_id, true).await?;
Ok(())
}

View File

@@ -0,0 +1,8 @@
Message-Id: <lkjsdf01u@example.org>
Date: Sat, 14 Sep 2019 19:00:13 +0200
From: lmn <x@tux.org>
To: abc <abc@bcd.com>
Content-Type: text/plain; charset=utf-8
-- escaped footer 1
-- escaped footer 2